Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,17 @@ models:
max_concurrent_requests: 2
max_queue_size: 4
queue_timeout: 30s
routing:
rules:
- id: large-prompt
target_model: qwen/qwen3.6-35b-a3b
min_prompt_chars: 12000
- id: hard-work-keywords
target_model: qwen/qwen3.6-35b-a3b
any_keywords:
- architecture
- refactor
- security
ensure:
mode: command
command:
Expand Down
22 changes: 22 additions & 0 deletions configs/router.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,28 @@ models:
- /usr/local/bin/lmstudio-load-profile
- local-coder-large
timeout: 45s
- id: local-coder-auto
name: Local Coder Auto
backend: lmstudio
target_model: qwen3-coder-30b-a3b-instruct
context_window: 65536
max_output_tokens: 4096
tool_calls: true
max_concurrent_requests: 1
max_queue_size: 4
queue_timeout: 2m
routing:
rules:
- id: large-prompt
target_model: qwen/qwen3.6-35b-a3b
min_prompt_chars: 12000
- id: hard-work-keywords
target_model: qwen/qwen3.6-35b-a3b
any_keywords:
- architecture
- migration
- refactor
- security

backends:
- id: lmstudio
Expand Down
30 changes: 28 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,34 @@ The first router is deliberately simple:
- The router rewrites the request to the configured backend model.
- The backend handles inference.

Future routing can add deterministic policy, queueing, health-aware selection,
RouteLLM-style strong/weak model routing, and second-pass review workflows.
Model aliases can also use deterministic request routing rules. The alias still
has a default `target_model`, but ordered rules can choose a different target
from cheap request metadata before the request is proxied:

```yaml
models:
- id: local-coder-auto
backend: lmstudio
target_model: qwen3-coder-30b-a3b-instruct
routing:
rules:
- id: large-prompt
target_model: qwen/qwen3.6-35b-a3b
min_prompt_chars: 12000
- id: hard-work-keywords
target_model: qwen/qwen3.6-35b-a3b
any_keywords:
- architecture
- migration
- refactor
- security
```

Rules are evaluated in order. A rule matches only when all configured numeric
conditions pass; `any_keywords` is an OR condition across the lower-cased
request text. If no rule matches, the alias uses its default `target_model`.
Future routing can add health-aware selection, RouteLLM-style strong/weak model
routing, and second-pass review workflows.

## Request Limits

Expand Down
60 changes: 60 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"net/url"
"os"
"strings"
"time"

"gopkg.in/yaml.v3"
Expand Down Expand Up @@ -34,6 +35,7 @@ type ModelConfig struct {
MaxQueueSize int `yaml:"max_queue_size"`
QueueTimeout string `yaml:"queue_timeout"`
Ensure EnsureConfig
Routing RoutingConfig `yaml:"routing"`
}

type BackendConfig struct {
Expand All @@ -49,6 +51,20 @@ type EnsureConfig struct {
Timeout string `yaml:"timeout"`
}

type RoutingConfig struct {
Rules []RoutingRuleConfig `yaml:"rules"`
}

type RoutingRuleConfig struct {
ID string `yaml:"id"`
TargetModel string `yaml:"target_model"`
MinPromptChars int `yaml:"min_prompt_chars"`
MaxPromptChars int `yaml:"max_prompt_chars"`
MinOutputTokens int `yaml:"min_output_tokens"`
MaxOutputTokens int `yaml:"max_output_tokens"`
AnyKeywords []string `yaml:"any_keywords"`
}

type CommandArgs []string

func (args *CommandArgs) UnmarshalYAML(value *yaml.Node) error {
Expand Down Expand Up @@ -152,6 +168,9 @@ func (cfg Config) Validate() error {
if err := model.Ensure.Validate(); err != nil {
return fmt.Errorf("model %q ensure is invalid: %w", model.ID, err)
}
if err := model.Routing.Validate(); err != nil {
return fmt.Errorf("model %q routing is invalid: %w", model.ID, err)
}
if _, ok := models[model.ID]; ok {
return fmt.Errorf("model %q is duplicated", model.ID)
}
Expand All @@ -161,6 +180,47 @@ func (cfg Config) Validate() error {
return nil
}

func (routing RoutingConfig) Validate() error {
for index, rule := range routing.Rules {
if rule.TargetModel == "" {
return fmt.Errorf("rule %d target_model is required", index)
}
if rule.MinPromptChars < 0 {
return fmt.Errorf("rule %d min_prompt_chars must be non-negative", index)
}
if rule.MaxPromptChars < 0 {
return fmt.Errorf("rule %d max_prompt_chars must be non-negative", index)
}
if rule.MinPromptChars > 0 && rule.MaxPromptChars > 0 && rule.MinPromptChars > rule.MaxPromptChars {
return fmt.Errorf("rule %d min_prompt_chars must be less than or equal to max_prompt_chars", index)
}
if rule.MinOutputTokens < 0 {
return fmt.Errorf("rule %d min_output_tokens must be non-negative", index)
}
if rule.MaxOutputTokens < 0 {
return fmt.Errorf("rule %d max_output_tokens must be non-negative", index)
}
if rule.MinOutputTokens > 0 && rule.MaxOutputTokens > 0 && rule.MinOutputTokens > rule.MaxOutputTokens {
return fmt.Errorf("rule %d min_output_tokens must be less than or equal to max_output_tokens", index)
}
hasCondition := rule.MinPromptChars > 0 ||
rule.MaxPromptChars > 0 ||
rule.MinOutputTokens > 0 ||
rule.MaxOutputTokens > 0 ||
len(rule.AnyKeywords) > 0
if !hasCondition {
return fmt.Errorf("rule %d must define at least one condition", index)
}
for keywordIndex, keyword := range rule.AnyKeywords {
if strings.TrimSpace(keyword) == "" {
return fmt.Errorf("rule %d any_keywords[%d] must not be empty", index, keywordIndex)
}
}
}

return nil
}

func (cfg Config) Model(id string) (ModelConfig, bool) {
for _, model := range cfg.Models {
if model.ID == id {
Expand Down
57 changes: 57 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,63 @@ func TestValidateQueueSettings(t *testing.T) {
}
}

func TestValidateRoutingRules(t *testing.T) {
t.Parallel()

cfg := Config{
Models: []ModelConfig{{
ID: "local-coder",
Backend: "lmstudio",
TargetModel: "fast-model",
Routing: RoutingConfig{
Rules: []RoutingRuleConfig{{
ID: "large-prompt",
TargetModel: "deep-model",
MinPromptChars: 12000,
AnyKeywords: []string{"refactor"},
}},
},
}},
Backends: []BackendConfig{{
ID: "lmstudio",
BaseURL: "http://127.0.0.1:1234/v1",
}},
}

if err := cfg.Validate(); err != nil {
t.Fatalf("validate config: %v", err)
}
}

func TestValidateRoutingRuleRequiresCondition(t *testing.T) {
t.Parallel()

cfg := Config{
Models: []ModelConfig{{
ID: "local-coder",
Backend: "lmstudio",
TargetModel: "fast-model",
Routing: RoutingConfig{
Rules: []RoutingRuleConfig{{
TargetModel: "deep-model",
}},
},
}},
Backends: []BackendConfig{{
ID: "lmstudio",
BaseURL: "http://127.0.0.1:1234/v1",
}},
}

err := cfg.Validate()
if err == nil {
t.Fatal("expected validation error")
}
if !strings.Contains(err.Error(), "routing") {
t.Fatalf("expected routing error, got: %v", err)
}
}

func TestLoadEnsureCommandString(t *testing.T) {
t.Parallel()

Expand Down
Loading
Loading