Skip to content

Commit 572d176

Browse files
Alexey Panfilovclaude
andcommitted
feat(adminapi): role-specific presets + dedicated OR slot per role
Replaces the single catch-all "workhorse" OR slot with one slot per routing role (simple-or / default-or / complex-or / compaction-or) so each can be reassigned independently from the admin UI without side effects on the other roles. Also adds a "Suggest for <role>" button to the routing table. Clicking it applies a role-specific preset to the model catalog: transparent include/exclude filters + a sort strategy — no opaque quality score, no curated shortlist, just filters the user can read and relax. Presets simple tools, multilingual Russian-capable families, ≤ $1/M prompt, no :free / -coder / -vl variants. Sort: prompt price asc. default same but ≤ $2/M prompt. complex adds reasoning requirement, ≤ $5/M prompt. Claude via bridge remains the recommended option when configured. multimodal vision + tools, multilingual, ≤ $2/M prompt. Note: native audio transcription still uses direct Gemini outside OR. compaction ctx ≥ 32k, multilingual, sorted by COMPLETION price — summaries are output-heavy, so ranking by prompt price misrepresents the actual cost driver. classifier multilingual, ≤ $0.1/M prompt. Local Ollama stays primary. The "fallback" role intentionally has no preset: it should point to a DIRECT-provider slot (Gemini in our config) so an OpenRouter outage doesn't take the whole bot down. Browsing OR candidates here would be misleading. Filter definitions live in internal/adminapi/recommend.go with a conservative multilingual allowlist (Qwen3, Qwen Plus/Max/Turbo, GLM 4.5+, Kimi, Gemini 2.5/3.x, Mistral Large/Medium, Grok 3/4, DeepSeek). English-primary small variants (nvidia, cohere-r-small) and specialised -coder/-vl fine-tunes are excluded from general roles so the top of the list doesn't fill up with things that don't speak Russian well. UI The models section is now one htmx-swappable div (banner + filters + table). When a preset is active, a banner at the top names the role, shows the preset's human-readable description, and offers a Clear button to return to the raw catalog. Config config.yaml (dev + deploy) splits "workhorse" into four OR slots so the model id per slot can diverge over time: simple-or qwen/qwen3.5-flash-02-23 — $0.065, 1M ctx, V T R default-or qwen/qwen3.5-flash-02-23 — same initial model complex-or qwen/qwen3-235b-a22b-thinking... — fallback when bridge off compaction-or qwen/qwen3.5-9b — 262k ctx, cheap output routing.{simple,default,complex,compaction} point at the new slots; fallback stays on direct Gemini flash-lite. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b2c8b3e commit 572d176

9 files changed

Lines changed: 372 additions & 87 deletions

File tree

config/config.yaml

Lines changed: 34 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -9,28 +9,35 @@ telegram:
99
# Known providers: openrouter, gemini, ollama, claude-bridge, local, hf-tei, openai.
1010
# The key "embedding" is reserved for the MCP embedding provider.
1111
models:
12-
# --- OpenRouter — one API key, many models. Add as many entries as you need. ---
13-
workhorse:
12+
# --- OpenRouter slots — one per role so each can be reassigned independently
13+
# from the admin UI. Initial models are suggestions; override via UI. ---
14+
simple-or:
1415
provider: openrouter
15-
model: ${OPENROUTER_MODEL:-deepseek/deepseek-chat-v3.1}
16+
model: qwen/qwen3.5-flash-02-23 # V T R, 1M ctx, $0.065/$0.26
17+
api_key: ${OPENROUTER_API_KEY}
18+
max_tokens: 2048
19+
base_url: https://openrouter.ai/api/v1
20+
21+
default-or:
22+
provider: openrouter
23+
model: qwen/qwen3.5-flash-02-23 # same as simple initially; diverge in UI
1624
api_key: ${OPENROUTER_API_KEY}
1725
max_tokens: 4096
1826
base_url: https://openrouter.ai/api/v1
19-
vision: true
20-
21-
# Example: dedicate a separate OpenRouter model to a routing role.
22-
# complex-or:
23-
# provider: openrouter
24-
# model: anthropic/claude-sonnet-4.5
25-
# api_key: ${OPENROUTER_API_KEY}
26-
# max_tokens: 8192
27-
# base_url: https://openrouter.ai/api/v1
28-
# cheap-or:
29-
# provider: openrouter
30-
# model: google/gemini-2.5-flash-lite
31-
# api_key: ${OPENROUTER_API_KEY}
32-
# max_tokens: 2048
33-
# base_url: https://openrouter.ai/api/v1
27+
28+
complex-or: # alt to claude-bridge if bridge is off
29+
provider: openrouter
30+
model: qwen/qwen3-235b-a22b-thinking-2507
31+
api_key: ${OPENROUTER_API_KEY}
32+
max_tokens: 8192
33+
base_url: https://openrouter.ai/api/v1
34+
35+
compaction-or:
36+
provider: openrouter
37+
model: qwen/qwen3.5-9b # min workable, 262k ctx, $0.10/$0.15
38+
api_key: ${OPENROUTER_API_KEY}
39+
max_tokens: 2048
40+
base_url: https://openrouter.ai/api/v1
3441

3542
# --- Gemini (direct Google API) — fallback + multimodal/transcription. ---
3643
gemini-flash-lite:
@@ -68,15 +75,15 @@ models:
6875
max_tokens: 120
6976

7077
routing:
71-
simple: workhorse # level 1: simple/cheap tasks
72-
default: workhorse # level 2: moderate tasks (agentic loop)
73-
complex: claude-bridge # level 3: complex reasoning via Claude bridge
74-
fallback: gemini-flash-lite
75-
multimodal: gemini-flash # vision/audio — also used for voice transcription
76-
compaction: workhorse
77-
classifier: classifier # rates complexity 1/2/3
78-
classifier_timeout: 15 # seconds
79-
classifier_min_length: 0 # 0 = always classify; >0 = min chars; <0 = disabled
78+
simple: simple-or
79+
default: default-or
80+
complex: claude-bridge # via Anthropic Max subscription (host bridge)
81+
compaction: compaction-or
82+
fallback: gemini-flash-lite # DIRECT Google — different vendor, survives OR outage
83+
multimodal: gemini-flash # DIRECT Google — native input_audio for voice
84+
classifier: classifier # local Ollama
85+
classifier_timeout: 15
86+
classifier_min_length: 0 # 0 = always; >0 = min chars; <0 = disabled
8087

8188
tool_filter:
8289
top_k: 20

internal/adminapi/adminapi_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ func TestTemplatesParse(t *testing.T) {
4141
cases := map[string]any{
4242
viewIndex: data,
4343
viewRouting: data.Routing, // routing view takes uiRouting directly
44-
viewModelsTable: data,
44+
viewModelsBrowser: data,
4545
}
4646
for v, d := range cases {
4747
t.Run(v, func(t *testing.T) {

internal/adminapi/handlers.go

Lines changed: 57 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ import (
1313
// --- View data ---
1414

1515
type uiRole struct {
16-
Name string // e.g. "default", "reasoner"
17-
Current string // slot name the role currently points at
18-
ModelID string // underlying model id of that slot (if OR-backed); empty otherwise
16+
Name string // e.g. "default", "complex"
17+
Current string // slot name the role currently points at
18+
ModelID string // underlying model id of that slot (if OR-backed); empty otherwise
19+
HasPreset bool // true when a "Suggest" preset is defined for this role
1920
}
2021

2122
type uiSlot struct {
@@ -40,11 +41,13 @@ type uiRouting struct {
4041
}
4142

4243
type uiFilters struct {
43-
Search string
44-
Free bool
45-
Vision bool
46-
Tools bool
47-
Reasoning bool
44+
Search string
45+
Free bool
46+
Vision bool
47+
Tools bool
48+
Reasoning bool
49+
ActivePreset string // role name when a preset is applied; empty otherwise
50+
PresetDescription string // human-readable summary for the banner
4851
}
4952

5053
type indexData struct {
@@ -68,7 +71,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
6871
func (s *Server) handleModels(w http.ResponseWriter, r *http.Request) {
6972
data := s.buildIndexData(r) // reuses model filtering + slots
7073
w.Header().Set("Content-Type", "text/html; charset=utf-8")
71-
if err := render(w, viewModelsTable, data); err != nil {
74+
if err := render(w, viewModelsBrowser, data); err != nil {
7275
s.logger.Error("render models_table", "err", err)
7376
http.Error(w, "render error", http.StatusInternalServerError)
7477
}
@@ -180,19 +183,45 @@ func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) {
180183
// Re-render just the tbody.
181184
data := s.buildIndexData(r)
182185
w.Header().Set("Content-Type", "text/html; charset=utf-8")
183-
if err := render(w, viewModelsTable, data); err != nil {
186+
if err := render(w, viewModelsBrowser, data); err != nil {
184187
s.logger.Error("render models after refresh", "err", err)
185188
}
186189
}
187190

188191
// --- Data builders ---
189192

190193
func (s *Server) buildIndexData(r *http.Request) indexData {
191-
// Tool calling is required for the agentic loop, so default the filter ON
192-
// when the page is first loaded (no query string yet). Once the user
193-
// submits the filters form, whatever they sent wins — including unchecking
194-
// Tools to browse the full catalog.
195194
q := r.URL.Query()
195+
preset := q.Get("preset")
196+
197+
var allCaps map[string]llm.Capabilities
198+
if s.capStore != nil {
199+
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
200+
defer cancel()
201+
allCaps, _ = s.capStore.GetAllCapabilities(ctx, "openrouter")
202+
}
203+
204+
// Preset path — pre-filter + pre-sort via the role's preset. Checkbox
205+
// filters are ignored on this path: the preset is a complete override.
206+
if p, ok := rolePresets[preset]; ok {
207+
return indexData{
208+
Routing: s.buildRouting(),
209+
Slots: s.openRouterSlots(),
210+
Models: applyPreset(allCaps, preset),
211+
Filters: uiFilters{
212+
ActivePreset: preset,
213+
PresetDescription: p.Description,
214+
// reflect preset intent in the checkboxes so user can tell what's on
215+
Tools: requiresTools(preset),
216+
Vision: preset == "multimodal",
217+
Reasoning: preset == "complex",
218+
},
219+
}
220+
}
221+
222+
// Manual filter path — Tools defaults ON on initial page load (empty
223+
// query string), since the agentic loop requires tool calling. Once the
224+
// user submits the filters form, whatever they send wins.
196225
formSubmitted := len(q) > 0
197226
f := uiFilters{
198227
Search: strings.ToLower(strings.TrimSpace(q.Get("q"))),
@@ -202,13 +231,6 @@ func (s *Server) buildIndexData(r *http.Request) indexData {
202231
Reasoning: q.Get("reasoning") != "",
203232
}
204233

205-
var allCaps map[string]llm.Capabilities
206-
if s.capStore != nil {
207-
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
208-
defer cancel()
209-
allCaps, _ = s.capStore.GetAllCapabilities(ctx, "openrouter")
210-
}
211-
212234
models := make([]uiModel, 0, len(allCaps))
213235
for id, c := range allCaps {
214236
free := c.Free()
@@ -257,6 +279,15 @@ func (s *Server) buildIndexData(r *http.Request) indexData {
257279
}
258280
}
259281

282+
// requiresTools returns true when the preset's filter requires tool-calling.
283+
func requiresTools(preset string) bool {
284+
switch preset {
285+
case "simple", "default", "complex", "multimodal":
286+
return true
287+
}
288+
return false
289+
}
290+
260291
func (s *Server) buildRouting() uiRouting {
261292
cfg := s.router.GetConfig()
262293
allSlots := s.router.ProviderNames()
@@ -277,12 +308,14 @@ func (s *Server) buildRouting() uiRouting {
277308
"compaction": cfg.Compaction,
278309
}
279310
order := []string{"simple", "default", "complex", "multimodal", "fallback", "classifier", "compaction"}
311+
presets := presetRoles()
280312
roles := make([]uiRole, 0, len(order))
281313
for _, r := range order {
282314
roles = append(roles, uiRole{
283-
Name: r,
284-
Current: roleCur[r],
285-
ModelID: orSlots[roleCur[r]],
315+
Name: r,
316+
Current: roleCur[r],
317+
ModelID: orSlots[roleCur[r]],
318+
HasPreset: presets[r],
286319
})
287320
}
288321
return uiRouting{Roles: roles, AllSlots: allSlots}

0 commit comments

Comments
 (0)