Skip to content

Commit 4b463e5

Browse files
Alexey Panfilovclaude
andcommitted
feat: always run classifier, default to local for simple tasks
Classifier (free local model) now runs on every message by default. classifier_min_length=0 means always classify, <0 means disabled. Short messages no longer skip to primary — they get classified too. Images route to local model first if it supports vision. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 79332dc commit 4b463e5

3 files changed

Lines changed: 43 additions & 11 deletions

File tree

config/config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ routing:
100100
multimodal: gemini-flash # vision/audio fallback (used when primary lacks vision)
101101
compaction_model: deepseek
102102
classifier: ollama # gemma3:1b rates complexity 1/2/3
103-
classifier_min_length: 100
103+
classifier_min_length: 0 # 0 = always classify; >0 = min chars; <0 = disabled
104104

105105
tool_filter:
106106
top_k: 20

internal/llm/router.go

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -343,8 +343,12 @@ func (r *Router) pick(ctx context.Context, messages []Message) Provider {
343343
}
344344
}
345345

346-
// Multimodal routing — use dedicated model only if the active provider can't handle images
346+
// Multimodal routing — prefer local/primary if they support vision
347347
if multimodal {
348+
if p := r.get(cfg.Local); p != nil && supportsVision(p) {
349+
slog.Info("routing", "reason", "local+vision", "provider", p.Name())
350+
return p
351+
}
348352
if p := r.get(cfg.Primary); p != nil && supportsVision(p) {
349353
slog.Info("routing", "reason", "primary+vision", "provider", p.Name())
350354
return p
@@ -355,25 +359,27 @@ func (r *Router) pick(ctx context.Context, messages []Message) Provider {
355359
}
356360
}
357361

358-
// Classifier-based three-level routing: 1=local, 2=primary, 3=reasoner
359-
if cfg.ClassifierMinLen > 0 {
360-
if text := lastUserText(messages); len([]rune(text)) >= cfg.ClassifierMinLen {
362+
// Classifier-based three-level routing: 1=local, 2=primary, 3=reasoner.
363+
// ClassifierMinLen > 0: only classify messages longer than threshold.
364+
// ClassifierMinLen == 0: always classify (classifier is a free local model).
365+
// ClassifierMinLen < 0: disabled entirely.
366+
if cfg.ClassifierMinLen >= 0 && r.get(cfg.Classifier) != nil {
367+
text := lastUserText(messages)
368+
if cfg.ClassifierMinLen == 0 || len([]rune(text)) >= cfg.ClassifierMinLen {
361369
level := r.classify(ctx, text)
362370
switch level {
363371
case 1:
364372
if p := r.get(cfg.Local); p != nil {
365373
slog.Info("routing", "reason", "classifier→local", "level", 1, "provider", p.Name())
366374
return p
367375
}
368-
// local not configured — fall through to primary
369376
case 3:
370377
if p := r.get(cfg.Reasoner); p != nil {
371378
slog.Info("routing", "reason", "classifier→reasoner", "level", 3, "provider", p.Name())
372379
return p
373380
}
374-
// reasoner not configured — fall through to primary
375381
}
376-
// level 2 or fallback from 1/3 → use primary
382+
// level 2 or fallback primary
377383
}
378384
}
379385

internal/llm/router_test.go

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -254,23 +254,49 @@ func TestRouter_ClassifierSkippedForShortMessages(t *testing.T) {
254254
}
255255
}
256256

257-
// TestRouter_ClassifierDisabled: minLen=0 → classifier никогда не вызывается.
257+
// TestRouter_ClassifierDisabled: minLen<0 → classifier никогда не вызывается.
258258
func TestRouter_ClassifierDisabled(t *testing.T) {
259259
classifier := &mockProvider{name: "classifier"}
260260
primary := &mockProvider{name: "primary", resp: Response{Content: "ok"}}
261261

262262
r := newTestRouter(RouterConfig{
263263
Primary: "primary",
264264
Classifier: "classifier",
265-
ClassifierMinLen: 0, // отключён
265+
ClassifierMinLen: -1, // отключён
266266
}, map[string]Provider{
267267
"primary": primary,
268268
"classifier": classifier,
269269
})
270270

271271
_, _ = r.Chat(context.Background(), []Message{{Role: "user", Content: "long message that would normally trigger classifier"}}, "", nil)
272272
if classifier.calls != 0 {
273-
t.Errorf("classifier should be disabled when minLen=0, got %d calls", classifier.calls)
273+
t.Errorf("classifier should be disabled when minLen<0, got %d calls", classifier.calls)
274+
}
275+
}
276+
277+
// TestRouter_ClassifierAlwaysWhenZero: minLen=0 → classifier вызывается для любого сообщения.
278+
func TestRouter_ClassifierAlwaysWhenZero(t *testing.T) {
279+
classifier := &mockProvider{name: "classifier", resp: Response{Content: "1"}}
280+
local := &mockProvider{name: "local", resp: Response{Content: "local"}}
281+
primary := &mockProvider{name: "primary", resp: Response{Content: "cloud"}}
282+
283+
r := newTestRouter(RouterConfig{
284+
Local: "local",
285+
Primary: "primary",
286+
Classifier: "classifier",
287+
ClassifierMinLen: 0, // всегда классифицировать
288+
}, map[string]Provider{
289+
"local": local,
290+
"primary": primary,
291+
"classifier": classifier,
292+
})
293+
294+
resp, _ := r.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}, "", nil)
295+
if classifier.calls != 1 {
296+
t.Errorf("classifier should be called for any message when minLen=0, got %d calls", classifier.calls)
297+
}
298+
if resp.Content != "local" {
299+
t.Errorf("expected local, got %q", resp.Content)
274300
}
275301
}
276302

0 commit comments

Comments
 (0)