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
2d5586a4c7
commit
5e2f7078d9
23 changed files with 638 additions and 104 deletions
2
delete_test_blocks.sh
Normal file
2
delete_test_blocks.sh
Normal file
|
|
@ -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
|
||||||
1
fix_frontend.sh
Normal file
1
fix_frontend.sh
Normal file
|
|
@ -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
|
||||||
1
fix_react.sh
Normal file
1
fix_react.sh
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
sed -i 's/ )}/ )}\n <\/Field>\n <Field/g' web/frontend/src/components/models/add-model-sheet.tsx
|
||||||
7
fix_react2.js
Normal file
7
fix_react2.js
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
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
fix_react2.sh
Normal file
1
fix_react2.sh
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
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
|
||||||
3
fix_tests.sh
Normal file
3
fix_tests.sh
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
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
|
||||||
30
patch_config.diff
Normal file
30
patch_config.diff
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<<<<<<< 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
|
||||||
170
patch_form_model.diff
Normal file
170
patch_form_model.diff
Normal file
|
|
@ -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<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
|
||||||
29
patch_router_test.diff
Normal file
29
patch_router_test.diff
Normal file
|
|
@ -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
|
||||||
|
|
@ -41,13 +41,13 @@ type AgentInstance struct {
|
||||||
SkillsFilter []string
|
SkillsFilter []string
|
||||||
Candidates []providers.FallbackCandidate
|
Candidates []providers.FallbackCandidate
|
||||||
|
|
||||||
// Router is non-nil when model routing is configured and the light model
|
// Router is non-nil when model routing is configured and the tiers
|
||||||
// was successfully resolved. It scores each incoming message and decides
|
// were successfully resolved. It scores each incoming message and decides
|
||||||
// whether to route to LightCandidates or stay with Candidates.
|
// which model tier to use.
|
||||||
Router *routing.Router
|
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.
|
// 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.
|
// NewAgentInstance creates an agent instance from config.
|
||||||
|
|
@ -165,21 +165,53 @@ func NewAgentInstance(
|
||||||
// Resolve fallback candidates
|
// Resolve fallback candidates
|
||||||
candidates := resolveModelCandidates(cfg, defaults.Provider, model, fallbacks)
|
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.
|
// to avoid repeated model_list lookups on every incoming message.
|
||||||
var router *routing.Router
|
var router *routing.Router
|
||||||
var lightCandidates []providers.FallbackCandidate
|
var tierCandidates map[string][]providers.FallbackCandidate
|
||||||
if rc := defaults.Routing; rc != nil && rc.Enabled && rc.LightModel != "" {
|
if rc := defaults.Routing; rc != nil && rc.Enabled {
|
||||||
resolved := resolveModelCandidates(cfg, defaults.Provider, rc.LightModel, nil)
|
// Backward compatibility: handle old light_model/threshold config
|
||||||
if len(resolved) > 0 {
|
var tiers []routing.RoutingTier
|
||||||
router = routing.New(routing.RouterConfig{
|
if len(rc.Tiers) == 0 && rc.LightModel != "" {
|
||||||
LightModel: rc.LightModel,
|
tiers = []routing.RoutingTier{
|
||||||
Threshold: rc.Threshold,
|
{Model: rc.LightModel, Threshold: 0.0},
|
||||||
})
|
}
|
||||||
lightCandidates = resolved
|
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 {
|
} else {
|
||||||
logger.WarnCF("agent", "Routing light model not found; routing disabled",
|
for _, t := range rc.Tiers {
|
||||||
map[string]any{"light_model": rc.LightModel, "agent_id": agentID})
|
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,
|
SkillsFilter: skillsFilter,
|
||||||
Candidates: candidates,
|
Candidates: candidates,
|
||||||
Router: router,
|
Router: router,
|
||||||
LightCandidates: lightCandidates,
|
TierCandidates: tierCandidates,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2744,29 +2744,37 @@ func (al *AgentLoop) selectCandidates(
|
||||||
userMsg string,
|
userMsg string,
|
||||||
history []providers.Message,
|
history []providers.Message,
|
||||||
) (candidates []providers.FallbackCandidate, model string) {
|
) (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)
|
return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, usedLight, score := agent.Router.SelectModel(userMsg, history, agent.Model)
|
targetModelName, _, score := agent.Router.SelectModel(userMsg, history, agent.Model)
|
||||||
if !usedLight {
|
if targetModelName == agent.Model {
|
||||||
logger.DebugCF("agent", "Model routing: primary model selected",
|
logger.DebugCF("agent", "Model routing: primary model selected",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"agent_id": agent.ID,
|
"agent_id": agent.ID,
|
||||||
"score": score,
|
"score": score,
|
||||||
"threshold": agent.Router.Threshold(),
|
|
||||||
})
|
})
|
||||||
return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model)
|
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{
|
map[string]any{
|
||||||
"agent_id": agent.ID,
|
"agent_id": agent.ID,
|
||||||
"light_model": agent.Router.LightModel(),
|
"tier_model": targetModelName,
|
||||||
"score": score,
|
"score": score,
|
||||||
"threshold": agent.Router.Threshold(),
|
|
||||||
})
|
})
|
||||||
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.
|
// maybeSummarize triggers summarization if the session history exceeds thresholds.
|
||||||
|
|
|
||||||
|
|
@ -273,16 +273,21 @@ type SessionConfig struct {
|
||||||
IdentityLinks map[string][]string `json:"identity_links,omitempty"`
|
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.
|
// RoutingConfig controls the intelligent model routing feature.
|
||||||
// When enabled, each incoming message is scored against structural features
|
// When enabled, each incoming message is scored against structural features
|
||||||
// (message length, code blocks, tool call history, conversation depth, attachments).
|
// (message length, code blocks, tool call history, conversation depth, attachments).
|
||||||
// Messages scoring below Threshold are sent to LightModel; all others use the
|
// The router selects the appropriate tier based on the computed score.
|
||||||
// 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 {
|
type RoutingConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
LightModel string `json:"light_model"` // model_name from model_list to use for simple tasks
|
LightModel string `json:"light_model,omitempty"` // legacy: model_name from model_list to use for simple tasks
|
||||||
Threshold float64 `json:"threshold"` // complexity score in [0,1]; score >= threshold → primary model
|
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.
|
// SubTurnConfig configures the SubTurn execution system.
|
||||||
|
|
|
||||||
|
|
@ -9,17 +9,17 @@ import (
|
||||||
// or an attachment) before the heavy model is chosen.
|
// or an attachment) before the heavy model is chosen.
|
||||||
const defaultThreshold = 0.35
|
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.
|
// RouterConfig holds the validated model routing settings.
|
||||||
// It mirrors config.RoutingConfig but lives in pkg/routing to keep the
|
// It mirrors config.RoutingConfig but lives in pkg/routing to keep the
|
||||||
// dependency graph simple: pkg/agent resolves config → routing, not the reverse.
|
// dependency graph simple: pkg/agent resolves config → routing, not the reverse.
|
||||||
type RouterConfig struct {
|
type RouterConfig struct {
|
||||||
// LightModel is the model_name (from model_list) used for simple tasks.
|
Tiers []RoutingTier
|
||||||
LightModel string
|
|
||||||
|
|
||||||
// Threshold is the complexity score cutoff in [0, 1].
|
|
||||||
// score >= Threshold → primary (heavy) model.
|
|
||||||
// score < Threshold → light model.
|
|
||||||
Threshold float64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Router selects the appropriate model tier for each incoming message.
|
// 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.
|
// 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 {
|
func New(cfg RouterConfig) *Router {
|
||||||
if cfg.Threshold <= 0 {
|
|
||||||
cfg.Threshold = defaultThreshold
|
|
||||||
}
|
|
||||||
return &Router{
|
return &Router{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
classifier: &RuleClassifier{},
|
classifier: &RuleClassifier{},
|
||||||
|
|
@ -44,20 +40,18 @@ func New(cfg RouterConfig) *Router {
|
||||||
// newWithClassifier creates a Router with a custom Classifier.
|
// newWithClassifier creates a Router with a custom Classifier.
|
||||||
// Intended for unit tests that need to inject a deterministic scorer.
|
// Intended for unit tests that need to inject a deterministic scorer.
|
||||||
func newWithClassifier(cfg RouterConfig, c Classifier) *Router {
|
func newWithClassifier(cfg RouterConfig, c Classifier) *Router {
|
||||||
if cfg.Threshold <= 0 {
|
|
||||||
cfg.Threshold = defaultThreshold
|
|
||||||
}
|
|
||||||
return &Router{cfg: cfg, classifier: c}
|
return &Router{cfg: cfg, classifier: c}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SelectModel returns the model to use for this conversation turn along with
|
// SelectModel returns the model to use for this conversation turn along with
|
||||||
// the computed complexity score (for logging and debugging).
|
// the computed complexity score (for logging and debugging).
|
||||||
//
|
//
|
||||||
// - If score < cfg.Threshold: returns (cfg.LightModel, true, score)
|
// The router selects the tier whose threshold is <= score.
|
||||||
// - Otherwise: returns (primaryModel, false, 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
|
// The caller is responsible for resolving the returned model name into
|
||||||
// provider candidates (see AgentInstance.LightCandidates).
|
// provider candidates.
|
||||||
func (r *Router) SelectModel(
|
func (r *Router) SelectModel(
|
||||||
msg string,
|
msg string,
|
||||||
history []providers.Message,
|
history []providers.Message,
|
||||||
|
|
@ -65,18 +59,27 @@ func (r *Router) SelectModel(
|
||||||
) (model string, usedLight bool, score float64) {
|
) (model string, usedLight bool, score float64) {
|
||||||
features := ExtractFeatures(msg, history)
|
features := ExtractFeatures(msg, history)
|
||||||
score = r.classifier.Score(features)
|
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.
|
// Tiers returns the configured routing tiers.
|
||||||
func (r *Router) LightModel() string {
|
func (r *Router) Tiers() []RoutingTier {
|
||||||
return r.cfg.LightModel
|
return r.cfg.Tiers
|
||||||
}
|
|
||||||
|
|
||||||
// Threshold returns the complexity threshold in use.
|
|
||||||
func (r *Router) Threshold() float64 {
|
|
||||||
return r.cfg.Threshold
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -241,22 +241,9 @@ func TestRuleClassifier_ScoreDoesNotExceedOne(t *testing.T) {
|
||||||
|
|
||||||
// ── Router ───────────────────────────────────────────────────────────────────
|
// ── 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) {
|
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"
|
msg := "hi"
|
||||||
model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
|
model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
|
||||||
if !usedLight {
|
if !usedLight {
|
||||||
|
|
@ -268,7 +255,7 @@ func TestRouter_SelectModel_SimpleMessageUsesLight(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRouter_SelectModel_CodeBlockUsesPrimary(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```"
|
msg := "```go\nfmt.Println(\"hello\")\n```"
|
||||||
model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
|
model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
|
||||||
if usedLight {
|
if usedLight {
|
||||||
|
|
@ -280,7 +267,7 @@ func TestRouter_SelectModel_CodeBlockUsesPrimary(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRouter_SelectModel_AttachmentUsesPrimary(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"
|
msg := "can you analyze this? data:image/png;base64,abc123"
|
||||||
model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
|
model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
|
||||||
if usedLight {
|
if usedLight {
|
||||||
|
|
@ -292,7 +279,7 @@ func TestRouter_SelectModel_AttachmentUsesPrimary(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRouter_SelectModel_LongMessageUsesPrimary(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
|
// >200 token estimate: 210 * 3 = 630 chars
|
||||||
msg := strings.Repeat("word ", 210)
|
msg := strings.Repeat("word ", 210)
|
||||||
model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
|
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) {
|
func TestRouter_SelectModel_DeepToolChainUsesLight(t *testing.T) {
|
||||||
// Tool calls alone (0.25) don't cross the 0.35 threshold — acceptable behavior.
|
// 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.
|
// 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{
|
history := []providers.Message{
|
||||||
{Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "read_file"}, {Name: "write_file"}}},
|
{Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "read_file"}, {Name: "write_file"}}},
|
||||||
{Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "exec"}, {Name: "search"}}},
|
{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) {
|
func TestRouter_SelectModel_ToolChainPlusMediumUsesHeavy(t *testing.T) {
|
||||||
// Tool calls (0.25) + medium message (0.15) = 0.40 >= 0.35 → heavy
|
// 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{
|
history := []providers.Message{
|
||||||
{Role: "assistant", ToolCalls: []providers.ToolCall{
|
{Role: "assistant", ToolCalls: []providers.ToolCall{
|
||||||
{Name: "a"}, {Name: "b"}, {Name: "c"}, {Name: "d"},
|
{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) {
|
func TestRouter_SelectModel_CustomThreshold(t *testing.T) {
|
||||||
// Very low threshold: even a short message triggers heavy model
|
// 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
|
msg := strings.Repeat("word ", 55) // medium message → 0.15 >= 0.05
|
||||||
_, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
|
_, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
|
||||||
if usedLight {
|
if usedLight {
|
||||||
|
|
@ -347,7 +334,7 @@ func TestRouter_SelectModel_CustomThreshold(t *testing.T) {
|
||||||
|
|
||||||
func TestRouter_SelectModel_HighThreshold(t *testing.T) {
|
func TestRouter_SelectModel_HighThreshold(t *testing.T) {
|
||||||
// Very high threshold: even code blocks route to light
|
// 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```"
|
msg := "```go\nfmt.Println()\n```"
|
||||||
_, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
|
_, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
|
||||||
if !usedLight {
|
if !usedLight {
|
||||||
|
|
@ -355,10 +342,10 @@ func TestRouter_SelectModel_HighThreshold(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRouter_LightModel(t *testing.T) {
|
func TestRouter_Tiers(t *testing.T) {
|
||||||
r := New(RouterConfig{LightModel: "my-fast-model", Threshold: 0.35})
|
r := New(RouterConfig{Tiers: []RoutingTier{{Model: "my-fast-model", Threshold: 0.0}, {Model: "heavy-model", Threshold: 0.35}}})
|
||||||
if r.LightModel() != "my-fast-model" {
|
if r.Tiers()[0].Model != "my-fast-model" {
|
||||||
t.Errorf("LightModel: got %q, want %q", r.LightModel(), "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) {
|
func TestRouter_CustomClassifier_LowScore_SelectsLight(t *testing.T) {
|
||||||
r := newWithClassifier(
|
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},
|
&fixedScoreClassifier{score: 0.2},
|
||||||
)
|
)
|
||||||
_, usedLight, _ := r.SelectModel("anything", nil, "heavy")
|
_, 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) {
|
func TestRouter_CustomClassifier_HighScore_SelectsPrimary(t *testing.T) {
|
||||||
r := newWithClassifier(
|
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},
|
&fixedScoreClassifier{score: 0.8},
|
||||||
)
|
)
|
||||||
_, usedLight, _ := r.SelectModel("anything", nil, "heavy")
|
_, 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) {
|
func TestRouter_CustomClassifier_ExactThreshold_SelectsPrimary(t *testing.T) {
|
||||||
// score == threshold → primary (uses >= comparison)
|
// score == threshold → primary (uses >= comparison)
|
||||||
r := newWithClassifier(
|
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},
|
&fixedScoreClassifier{score: 0.5},
|
||||||
)
|
)
|
||||||
_, usedLight, _ := r.SelectModel("anything", nil, "heavy")
|
_, 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) {
|
func TestRouter_SelectModel_ReturnsScore(t *testing.T) {
|
||||||
r := newWithClassifier(
|
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},
|
&fixedScoreClassifier{score: 0.42},
|
||||||
)
|
)
|
||||||
_, _, score := r.SelectModel("anything", nil, "heavy")
|
_, _, score := r.SelectModel("anything", nil, "heavy")
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,9 @@ export function GenericForm({
|
||||||
bot_id: t("channels.form.desc.appId"),
|
bot_id: t("channels.form.desc.appId"),
|
||||||
websocket_url: t("channels.form.desc.wsUrl"),
|
websocket_url: t("channels.form.desc.wsUrl"),
|
||||||
dm_policy: t("channels.form.desc.genericField", { field: "DM policy" }),
|
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"),
|
group_allow_from: t("channels.form.desc.allowFrom"),
|
||||||
send_thinking_message: t("channels.form.desc.genericField", {
|
send_thinking_message: t("channels.form.desc.genericField", {
|
||||||
field: "thinking message behavior",
|
field: "thinking message behavior",
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import {
|
||||||
parseIntField,
|
parseIntField,
|
||||||
parseMultilineList,
|
parseMultilineList,
|
||||||
} from "@/components/config/form-model"
|
} from "@/components/config/form-model"
|
||||||
|
import { RoutingSection } from "@/components/config/routing-section"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
|
@ -217,6 +218,10 @@ export function ConfigPage() {
|
||||||
max_tool_iterations: maxToolIterations,
|
max_tool_iterations: maxToolIterations,
|
||||||
summarize_message_threshold: summarizeMessageThreshold,
|
summarize_message_threshold: summarizeMessageThreshold,
|
||||||
summarize_token_percent: summarizeTokenPercent,
|
summarize_token_percent: summarizeTokenPercent,
|
||||||
|
routing: {
|
||||||
|
enabled: form.routingEnabled,
|
||||||
|
tiers: form.routingTiers,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
|
|
@ -322,6 +327,13 @@ export function ConfigPage() {
|
||||||
|
|
||||||
<AgentDefaultsSection form={form} onFieldChange={updateField} />
|
<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} />
|
<RuntimeSection form={form} onFieldChange={updateField} />
|
||||||
|
|
||||||
<ExecSection 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 type JsonRecord = Record<string, unknown>
|
||||||
|
|
||||||
|
export interface RoutingTier {
|
||||||
|
model: string
|
||||||
|
threshold: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface CoreConfigForm {
|
export interface CoreConfigForm {
|
||||||
workspace: string
|
workspace: string
|
||||||
restrictToWorkspace: boolean
|
restrictToWorkspace: boolean
|
||||||
|
|
@ -23,6 +28,8 @@ export interface CoreConfigForm {
|
||||||
heartbeatInterval: string
|
heartbeatInterval: string
|
||||||
devicesEnabled: boolean
|
devicesEnabled: boolean
|
||||||
monitorUSB: boolean
|
monitorUSB: boolean
|
||||||
|
routingEnabled: boolean
|
||||||
|
routingTiers: RoutingTier[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LauncherForm {
|
export interface LauncherForm {
|
||||||
|
|
@ -85,6 +92,8 @@ export const EMPTY_FORM: CoreConfigForm = {
|
||||||
heartbeatInterval: "30",
|
heartbeatInterval: "30",
|
||||||
devicesEnabled: false,
|
devicesEnabled: false,
|
||||||
monitorUSB: true,
|
monitorUSB: true,
|
||||||
|
routingEnabled: false,
|
||||||
|
routingTiers: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EMPTY_LAUNCHER_FORM: LauncherForm = {
|
export const EMPTY_LAUNCHER_FORM: LauncherForm = {
|
||||||
|
|
@ -129,6 +138,25 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm {
|
||||||
const cron = asRecord(tools.cron)
|
const cron = asRecord(tools.cron)
|
||||||
const exec = asRecord(tools.exec)
|
const exec = asRecord(tools.exec)
|
||||||
const toolFeedback = asRecord(defaults.tool_feedback)
|
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 {
|
return {
|
||||||
workspace: asString(defaults.workspace) || EMPTY_FORM.workspace,
|
workspace: asString(defaults.workspace) || EMPTY_FORM.workspace,
|
||||||
|
|
@ -212,6 +240,11 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm {
|
||||||
devices.monitor_usb === undefined
|
devices.monitor_usb === undefined
|
||||||
? EMPTY_FORM.monitorUSB
|
? EMPTY_FORM.monitorUSB
|
||||||
: asBool(devices.monitor_usb),
|
: 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"
|
} from "@/components/shared-form"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
|
|
@ -21,6 +20,7 @@ import {
|
||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from "@/components/ui/sheet"
|
} from "@/components/ui/sheet"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
|
||||||
interface AddForm {
|
interface AddForm {
|
||||||
modelName: string
|
modelName: string
|
||||||
|
|
@ -103,7 +103,11 @@ export function AddModelSheet({
|
||||||
if (form.extraHeaders.trim()) {
|
if (form.extraHeaders.trim()) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(form.extraHeaders.trim())
|
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"
|
errors.extraHeaders = "Must be a valid JSON object"
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -115,7 +119,8 @@ export function AddModelSheet({
|
||||||
}
|
}
|
||||||
|
|
||||||
const setField =
|
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 }))
|
setForm((f) => ({ ...f, [key]: e.target.value }))
|
||||||
if (fieldErrors[key]) {
|
if (fieldErrors[key]) {
|
||||||
setFieldErrors((prev) => ({ ...prev, [key]: undefined }))
|
setFieldErrors((prev) => ({ ...prev, [key]: undefined }))
|
||||||
|
|
@ -144,7 +149,9 @@ export function AddModelSheet({
|
||||||
? Number(form.requestTimeout)
|
? Number(form.requestTimeout)
|
||||||
: undefined,
|
: undefined,
|
||||||
thinking_level: form.thinkingLevel.trim() || 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()
|
extra_body: form.extraBody.trim()
|
||||||
? JSON.parse(form.extraBody.trim())
|
? JSON.parse(form.extraBody.trim())
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|
@ -336,8 +343,12 @@ export function AddModelSheet({
|
||||||
aria-invalid={!!fieldErrors.extraHeaders}
|
aria-invalid={!!fieldErrors.extraHeaders}
|
||||||
/>
|
/>
|
||||||
{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")}
|
label={t("models.field.extraBody")}
|
||||||
hint={t("models.field.extraBodyHint")}
|
hint={t("models.field.extraBodyHint")}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import {
|
||||||
} from "@/components/shared-form"
|
} from "@/components/shared-form"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
|
|
@ -21,6 +20,7 @@ import {
|
||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from "@/components/ui/sheet"
|
} from "@/components/ui/sheet"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
|
||||||
interface EditForm {
|
interface EditForm {
|
||||||
apiKey: string
|
apiKey: string
|
||||||
|
|
@ -84,7 +84,9 @@ export function EditModelSheet({
|
||||||
? String(model.request_timeout)
|
? String(model.request_timeout)
|
||||||
: "",
|
: "",
|
||||||
thinkingLevel: model.thinking_level ?? "",
|
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
|
extraBody: model.extra_body
|
||||||
? JSON.stringify(model.extra_body, null, 2)
|
? JSON.stringify(model.extra_body, null, 2)
|
||||||
: "",
|
: "",
|
||||||
|
|
@ -95,7 +97,8 @@ export function EditModelSheet({
|
||||||
}, [model])
|
}, [model])
|
||||||
|
|
||||||
const setField =
|
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 }))
|
setForm((f) => ({ ...f, [key]: e.target.value }))
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
|
@ -103,7 +106,11 @@ export function EditModelSheet({
|
||||||
if (form.extraHeaders.trim()) {
|
if (form.extraHeaders.trim()) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(form.extraHeaders.trim())
|
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")
|
setError("Extra headers must be a valid JSON object")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -130,7 +137,9 @@ export function EditModelSheet({
|
||||||
? Number(form.requestTimeout)
|
? Number(form.requestTimeout)
|
||||||
: undefined,
|
: undefined,
|
||||||
thinking_level: form.thinkingLevel || 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()
|
extra_body: form.extraBody.trim()
|
||||||
? JSON.parse(form.extraBody.trim())
|
? JSON.parse(form.extraBody.trim())
|
||||||
: {},
|
: {},
|
||||||
|
|
@ -307,6 +316,9 @@ export function EditModelSheet({
|
||||||
value={form.extraHeaders}
|
value={form.extraHeaders}
|
||||||
onChange={setField("extraHeaders")}
|
onChange={setField("extraHeaders")}
|
||||||
placeholder='{"X-My-Header": "value"}'
|
placeholder='{"X-My-Header": "value"}'
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
label={t("models.field.extraBody")}
|
label={t("models.field.extraBody")}
|
||||||
hint={t("models.field.extraBodyHint")}
|
hint={t("models.field.extraBodyHint")}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,8 @@ export function ModelCard({
|
||||||
}: ModelCardProps) {
|
}: ModelCardProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const isOAuth = model.auth_method === "oauth"
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -211,7 +211,7 @@
|
||||||
"maxTokensField": "Max Tokens Field",
|
"maxTokensField": "Max Tokens Field",
|
||||||
"maxTokensFieldHint": "Override the request field name for max tokens, e.g. max_completion_tokens.",
|
"maxTokensFieldHint": "Override the request field name for max tokens, e.g. max_completion_tokens.",
|
||||||
"extraHeaders": "Extra Headers",
|
"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",
|
"extraBody": "Extra Body",
|
||||||
"extraBodyHint": "Additional JSON fields to inject into the request body, e.g. {\"reasoning_split\": true}."
|
"extraBodyHint": "Additional JSON fields to inject into the request body, e.g. {\"reasoning_split\": true}."
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue