mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
1943c3e660
Add three new files to pkg/routing/:
features.go — ExtractFeatures(msg, history) → Features
Computes five structural dimensions with zero keyword matching:
- TokenEstimate: rune_count/3 (CJK-safe token proxy)
- CodeBlockCount: ``` pairs in the message
- RecentToolCalls: tool call count in the last 6 history entries
- ConversationDepth: total messages in session
- HasAttachments: data URIs or media file extensions
classifier.go — Classifier interface + RuleClassifier
RuleClassifier uses a weighted sum that is capped at 1.0:
code block → +0.40 (triggers heavy model alone at 0.35 threshold)
token > 200 → +0.35 (triggers heavy model alone)
tool calls > 3 → +0.25
token 50-200 → +0.15
conversation depth > 10 → +0.10
attachment → 1.00 (hard gate, always heavy)
router.go — Router wraps config + Classifier
Router.SelectModel(msg, history, primaryModel) returns either the
configured light_model or the primary model depending on whether
the complexity score clears the threshold. Threshold defaults to
0.35 when zero/negative to prevent misconfiguration.
router_test.go — 34 tests covering all branches and edge cases
78 lines
2.5 KiB
Go
78 lines
2.5 KiB
Go
package routing
|
|
|
|
import (
|
|
"github.com/sipeed/picoclaw/pkg/providers"
|
|
)
|
|
|
|
// defaultThreshold is used when the config threshold is zero or negative.
|
|
// At 0.35 a message needs at least one strong signal (code block, long text,
|
|
// or an attachment) before the heavy model is chosen.
|
|
const defaultThreshold = 0.35
|
|
|
|
// 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
|
|
}
|
|
|
|
// Router selects the appropriate model tier for each incoming message.
|
|
// It is safe for concurrent use from multiple goroutines.
|
|
type Router struct {
|
|
cfg RouterConfig
|
|
classifier Classifier
|
|
}
|
|
|
|
// 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{},
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// - If score < cfg.Threshold: returns (cfg.LightModel, true)
|
|
// - Otherwise: returns (primaryModel, false)
|
|
//
|
|
// The caller is responsible for resolving the returned model name into
|
|
// provider candidates (see AgentInstance.LightCandidates).
|
|
func (r *Router) SelectModel(msg string, history []providers.Message, primaryModel string) (model string, usedLight bool) {
|
|
features := ExtractFeatures(msg, history)
|
|
score := r.classifier.Score(features)
|
|
if score < r.cfg.Threshold {
|
|
return r.cfg.LightModel, true
|
|
}
|
|
return primaryModel, false
|
|
}
|
|
|
|
// 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
|
|
}
|