Skip to content

Commit 5e5f68c

Browse files
Alexey Panfilovclaude
andcommitted
feat: dynamic model switching — /model <name> for any configured provider
- Router refactored to use providers map[string]Provider - /model list shows all available models - /model <name> switches to any configured model (validated) - /model reset clears override back to auto-routing - Added qwen_122b (qwen3.5-122b-a10b) and qwen_max (qwen3-max) - Routing roles (multimodal, reasoner) now configurable in config.yaml Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 8a30cfb commit 5e5f68c

7 files changed

Lines changed: 221 additions & 77 deletions

File tree

cmd/agent/main.go

Lines changed: 52 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -35,38 +35,69 @@ func main() {
3535
os.Exit(1)
3636
}
3737

38-
// Init LLM providers
38+
// Build providers map — all named LLM providers available for routing and /model switching.
39+
providers := make(map[string]llm.Provider)
40+
41+
addProvider := func(key string, p llm.Provider, e error) {
42+
if e != nil {
43+
logger.Warn("failed to init LLM provider", "key", key, "err", e)
44+
return
45+
}
46+
providers[key] = p
47+
}
48+
49+
// Primary (required)
3950
primary, err := llm.NewDeepSeek(cfg.Models.Default)
4051
if err != nil {
4152
logger.Error("failed to init primary LLM", "err", err)
4253
os.Exit(1)
4354
}
55+
providers[cfg.Routing.Default] = primary
4456

45-
var fallback llm.Provider
57+
// Optional providers
58+
if cfg.Models.Reasoner.APIKey != "" {
59+
p, e := llm.NewDeepSeek(cfg.Models.Reasoner)
60+
addProvider("reasoner", p, e)
61+
}
4662
if cfg.Models.FlashLite.APIKey != "" {
47-
fallback, err = llm.NewGemini(cfg.Models.FlashLite)
48-
if err != nil {
49-
logger.Warn("failed to init fallback LLM", "err", err)
50-
}
63+
p, e := llm.NewGemini(cfg.Models.FlashLite)
64+
addProvider("flash_lite", p, e)
5165
}
52-
53-
var reasoner llm.Provider
54-
if cfg.Models.Reasoner.APIKey != "" {
55-
reasoner, err = llm.NewDeepSeek(cfg.Models.Reasoner)
56-
if err != nil {
57-
logger.Warn("failed to init reasoner LLM", "err", err)
58-
}
66+
if cfg.Models.Multimodal.APIKey != "" {
67+
p, e := llm.NewGeminiMultimodal(cfg.Models.Multimodal)
68+
addProvider("multimodal", p, e)
69+
}
70+
if cfg.Models.QwenFlash.APIKey != "" {
71+
p, e := llm.NewQwen(cfg.Models.QwenFlash)
72+
addProvider("qwen_flash", p, e)
73+
}
74+
if cfg.Models.Qwen122b.APIKey != "" {
75+
p, e := llm.NewQwen(cfg.Models.Qwen122b)
76+
addProvider("qwen_122b", p, e)
77+
}
78+
if cfg.Models.QwenMax.APIKey != "" {
79+
p, e := llm.NewQwen(cfg.Models.QwenMax)
80+
addProvider("qwen_max", p, e)
5981
}
6082

61-
var multimodal llm.Provider
62-
if cfg.Models.Multimodal.APIKey != "" {
63-
multimodal, err = llm.NewGeminiMultimodal(cfg.Models.Multimodal)
64-
if err != nil {
65-
logger.Warn("failed to init multimodal LLM", "err", err)
66-
}
83+
// Default role keys if not specified
84+
multimodalKey := cfg.Routing.Multimodal
85+
if multimodalKey == "" {
86+
multimodalKey = "multimodal"
87+
}
88+
reasonerKey := cfg.Routing.Reasoner
89+
if reasonerKey == "" {
90+
reasonerKey = "reasoner"
6791
}
6892

69-
router := llm.NewRouter(primary, fallback, reasoner, multimodal, cfg.Routing.ClassifierMinLength)
93+
router := llm.NewRouter(providers, llm.RouterConfig{
94+
Primary: cfg.Routing.Default,
95+
Fallback: cfg.Routing.Fallback,
96+
Multimodal: multimodalKey,
97+
Reasoner: reasonerKey,
98+
Classifier: cfg.Routing.Classifier,
99+
ClassifierMinLen: cfg.Routing.ClassifierMinLength,
100+
})
70101

71102
// Init store (SQLite if data dir exists, otherwise memory)
72103
var s store.Store
@@ -118,7 +149,7 @@ func main() {
118149
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
119150
defer stop()
120151

121-
logger.Info("agent started", "model", router.Name(), "mcp_servers", len(mcpServers))
152+
logger.Info("agent started", "model", router.Name(), "providers", len(providers), "mcp_servers", len(mcpServers))
122153
handler.Start(ctx)
123154
logger.Info("agent stopped")
124155
}

config/config.yaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,30 @@ models:
3333
provider: gemini
3434
model: gemini-embedding-001
3535
api_key: ${GEMINI_API_KEY}
36+
qwen_flash:
37+
provider: qwen
38+
model: qwen3.5-flash
39+
api_key: ${QWEN_API_KEY}
40+
max_tokens: 1
41+
base_url: https://dashscope-intl.aliyuncs.com/compatible-mode/v1
42+
qwen_122b:
43+
provider: qwen
44+
model: qwen3.5-122b-a10b
45+
api_key: ${QWEN_API_KEY}
46+
max_tokens: 4096
47+
base_url: https://dashscope-intl.aliyuncs.com/compatible-mode/v1
48+
qwen_max:
49+
provider: qwen
50+
model: qwen3-max
51+
api_key: ${QWEN_API_KEY}
52+
max_tokens: 8192
53+
base_url: https://dashscope-intl.aliyuncs.com/compatible-mode/v1
3654

3755
routing:
3856
default: default
3957
fallback: flash_lite
4058
compaction_model: default
59+
classifier: qwen_flash
4160
classifier_min_length: 100
4261

4362
tool_filter:

internal/agent/agent.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,8 @@ func (a *Agent) Compact(ctx context.Context, chatID int64) error {
104104
return a.compacter.Compact(ctx, chatID, a.store)
105105
}
106106

107-
func (a *Agent) SetModel(override string) {
108-
a.router.SetOverride(override)
107+
func (a *Agent) SetModel(override string) error {
108+
return a.router.SetOverride(override)
109109
}
110110

111111
func (a *Agent) ModelName() string {
@@ -116,6 +116,10 @@ func (a *Agent) ModelOverride() string {
116116
return a.router.GetOverride()
117117
}
118118

119+
func (a *Agent) ListModels() []string {
120+
return a.router.ProviderNames()
121+
}
122+
119123
type ToolInfo struct {
120124
Name string
121125
ServerName string

internal/config/config.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,17 @@ type ModelsConfig struct {
4646
FlashLite ModelConfig `yaml:"flash_lite"`
4747
Multimodal ModelConfig `yaml:"multimodal"`
4848
Embedding ModelConfig `yaml:"embedding"`
49+
QwenFlash ModelConfig `yaml:"qwen_flash"`
50+
Qwen122b ModelConfig `yaml:"qwen_122b"`
51+
QwenMax ModelConfig `yaml:"qwen_max"`
4952
}
5053

5154
type RoutingConfig struct {
5255
Default string `yaml:"default"`
5356
Fallback string `yaml:"fallback"`
57+
Multimodal string `yaml:"multimodal"`
58+
Reasoner string `yaml:"reasoner"`
59+
Classifier string `yaml:"classifier"` // model for reasoning classifier; empty = use primary
5460
CompactionModel string `yaml:"compaction_model"`
5561
ClassifierMinLength int `yaml:"classifier_min_length"` // 0 = disabled
5662
}

internal/llm/qwen.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package llm
2+
3+
import "telegram-agent/internal/config"
4+
5+
func NewQwen(cfg config.ModelConfig) (*openAICompatProvider, error) {
6+
return newOpenAICompat(cfg, "", "qwen")
7+
}

internal/llm/router.go

Lines changed: 90 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"errors"
66
"log/slog"
7+
"sort"
78
"strings"
89
"sync"
910

@@ -12,97 +13,144 @@ import (
1213

1314
const classifierPrompt = `Does this message require deep step-by-step reasoning, mathematical proof, or complex multi-step analysis? Reply with only 'yes' or 'no'.`
1415

16+
// RouterConfig holds the keys into the providers map for special roles.
17+
type RouterConfig struct {
18+
Primary string
19+
Fallback string
20+
Multimodal string
21+
Reasoner string
22+
Classifier string // provider used for reasoning classification; falls back to Primary if empty
23+
ClassifierMinLen int // min rune length to run classifier; 0 = disabled
24+
}
25+
1526
// Router selects the appropriate LLM provider based on context.
16-
// Primary → Fallback on 5xx/network error.
17-
// Reasoner → selected by LLM classifier or explicit /model command.
18-
// Multimodal → selected when message contains image parts.
27+
// All providers are stored by name; special roles reference names from RouterConfig.
1928
type Router struct {
20-
primary Provider
21-
fallback Provider // used if primary is unavailable
22-
reasoner Provider // used for complex reasoning
23-
multimodal Provider // used when message contains images
24-
25-
classifierMinLen int // min message length to run classifier; 0 = disabled
29+
providers map[string]Provider
30+
cfg RouterConfig
2631

2732
mu sync.RWMutex
28-
override string // "reasoner" or "" (primary)
33+
override string // set via SetOverride; any key in providers, or "" for auto
2934

30-
OnFallback func(from, to string) // optional: called when fallback is triggered
31-
32-
logger *slog.Logger
35+
OnFallback func(from, to string)
36+
logger *slog.Logger
3337
}
3438

35-
func NewRouter(primary, fallback, reasoner, multimodal Provider, classifierMinLen int) *Router {
39+
func NewRouter(providers map[string]Provider, cfg RouterConfig) *Router {
3640
return &Router{
37-
primary: primary,
38-
fallback: fallback,
39-
reasoner: reasoner,
40-
multimodal: multimodal,
41-
classifierMinLen: classifierMinLen,
42-
logger: slog.Default(),
41+
providers: providers,
42+
cfg: cfg,
43+
logger: slog.Default(),
4344
}
4445
}
4546

4647
func (r *Router) Chat(ctx context.Context, messages []Message, systemPrompt string, tools []Tool) (Response, error) {
4748
provider := r.pick(ctx, messages)
4849

4950
resp, err := provider.Chat(ctx, messages, systemPrompt, tools)
50-
if err != nil && r.fallback != nil && provider != r.fallback && isUnavailable(err) {
51-
if r.OnFallback != nil {
52-
r.OnFallback(provider.Name(), r.fallback.Name())
51+
if err != nil && isUnavailable(err) {
52+
if fallback := r.get(r.cfg.Fallback); fallback != nil && fallback != provider {
53+
if r.OnFallback != nil {
54+
r.OnFallback(provider.Name(), fallback.Name())
55+
}
56+
resp, err = fallback.Chat(ctx, messages, systemPrompt, tools)
5357
}
54-
resp, err = r.fallback.Chat(ctx, messages, systemPrompt, tools)
5558
}
5659
return resp, err
5760
}
5861

62+
// Name returns the name of the currently active provider (respecting override).
5963
func (r *Router) Name() string {
6064
return r.pick(context.Background(), nil).Name()
6165
}
6266

63-
func (r *Router) SetOverride(model string) {
67+
// SetOverride sets a named model override. Pass "" to clear. Returns error if name is unknown.
68+
func (r *Router) SetOverride(name string) error {
69+
if name != "" {
70+
if _, ok := r.providers[name]; !ok {
71+
return errors.New("unknown model: " + name)
72+
}
73+
}
6474
r.mu.Lock()
65-
defer r.mu.Unlock()
66-
r.override = model
75+
r.override = name
76+
r.mu.Unlock()
77+
return nil
6778
}
6879

80+
// GetOverride returns the current override name (empty = auto).
6981
func (r *Router) GetOverride() string {
7082
r.mu.RLock()
7183
defer r.mu.RUnlock()
7284
return r.override
7385
}
7486

87+
// ProviderNames returns sorted list of all available provider names.
88+
func (r *Router) ProviderNames() []string {
89+
names := make([]string, 0, len(r.providers))
90+
for k := range r.providers {
91+
names = append(names, k)
92+
}
93+
sort.Strings(names)
94+
return names
95+
}
96+
7597
func (r *Router) pick(ctx context.Context, messages []Message) Provider {
98+
// Multimodal takes priority — only vision-capable models support image parts
99+
if p := r.get(r.cfg.Multimodal); p != nil && hasMultimodalContent(messages) {
100+
return p
101+
}
102+
76103
r.mu.RLock()
77104
override := r.override
78105
r.mu.RUnlock()
79106

80-
// Multimodal takes priority — DeepSeek doesn't support vision
81-
if r.multimodal != nil && hasMultimodalContent(messages) {
82-
return r.multimodal
83-
}
84-
85-
if override == "reasoner" && r.reasoner != nil {
86-
return r.reasoner
107+
if override != "" {
108+
if p := r.get(override); p != nil {
109+
return p
110+
}
87111
}
88112

89113
// Classifier-based routing to reasoner
90-
if r.reasoner != nil && r.classifierMinLen > 0 {
91-
if text := lastUserText(messages); len([]rune(text)) >= r.classifierMinLen {
92-
if r.classify(ctx, text) {
93-
return r.reasoner
114+
if r.cfg.ClassifierMinLen > 0 {
115+
if p := r.get(r.cfg.Reasoner); p != nil {
116+
if text := lastUserText(messages); len([]rune(text)) >= r.cfg.ClassifierMinLen {
117+
if r.classify(ctx, text) {
118+
return p
119+
}
94120
}
95121
}
96122
}
97123

98-
return r.primary
124+
if p := r.get(r.cfg.Primary); p != nil {
125+
return p
126+
}
127+
// Should never happen if config is valid
128+
for _, p := range r.providers {
129+
return p
130+
}
131+
return nil
132+
}
133+
134+
func (r *Router) get(key string) Provider {
135+
if key == "" {
136+
return nil
137+
}
138+
return r.providers[key]
99139
}
100140

101-
// classify calls the primary LLM with a minimal prompt to determine
102-
// if the message requires deep reasoning. Returns false on any error.
141+
// classify calls the classifier provider (or primary) to determine if the message
142+
// requires deep reasoning. Returns false on any error.
103143
func (r *Router) classify(ctx context.Context, text string) bool {
144+
classifierKey := r.cfg.Classifier
145+
if classifierKey == "" {
146+
classifierKey = r.cfg.Primary
147+
}
148+
provider := r.get(classifierKey)
149+
if provider == nil {
150+
return false
151+
}
104152
msgs := []Message{{Role: "user", Content: text}}
105-
resp, err := r.primary.Chat(ctx, msgs, classifierPrompt, nil)
153+
resp, err := provider.Chat(ctx, msgs, classifierPrompt, nil)
106154
if err != nil {
107155
r.logger.Warn("classifier call failed, using primary", "err", err)
108156
return false

0 commit comments

Comments
 (0)