From e0be5f0f98c1204c760c57d2740168282750d90c Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Mon, 20 Apr 2026 08:48:33 +0200 Subject: [PATCH] feat(config): replace single-model config with chain-based routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements escalation chains per skill with three-layer priority: 1. Caller override (model param) — no escalation 2. Per-skill chain from models.yaml 3. default_chain fallback New APIs: - Verifier() — fixed verifier for output validation - LlamaSwapURL() — base URL for warm-state probing - ChainFor(skill, override) — ordered model list for escalation Co-Authored-By: Claude Sonnet 4.6 --- config/models.yaml | 50 ++++++++++++++++----- internal/config/models.go | 35 ++++++++++----- internal/config/models_test.go | 82 ++++++++++++++++++++++++---------- 3 files changed, 122 insertions(+), 45 deletions(-) diff --git a/config/models.yaml b/config/models.yaml index bc612b5..9a1ea8a 100644 --- a/config/models.yaml +++ b/config/models.yaml @@ -1,13 +1,41 @@ -# Model routing table — three-layer priority: -# 1. model param in MCP tool call (caller override) -# 2. per-skill entry here -# 3. default (fallback) -default: ollama/qwen3-coder-30b-tuned +# Model routing chains — three-layer priority: +# 1. model param in MCP tool call (caller override — collapses to single entry, no escalation) +# 2. per-skill chain here +# 3. default_chain fallback + +verifier: claude-sonnet-4-6 # fixed verifier for all local tiers + +llama_swap_url: http://koala:8080 # for warm-state probing + +default_chain: + - ollama/qwen3-coder-30b-tuned + - claude-sonnet-4-6 skills: - tdd: ollama/qwen3-coder-30b-tuned - review: ollama/devstral-tuned - debug: ollama/deepseek-r1-tuned - retrospective: ollama/qwen3-coder-30b-tuned - spec: ollama/qwen3-coder-30b-tuned - trainer: ollama/qwen3-coder-30b-tuned + tdd: + chain: + - ollama/qwen3-coder-30b-tuned + - claude-sonnet-4-6 + review: + chain: + - ollama/devstral-tuned + - ollama/gemma4 + - claude-sonnet-4-6 + debug: + chain: + - ollama/deepseek-r1-tuned + - claude-sonnet-4-6 + spec: + chain: + - ollama/phi4 + - ollama/gemma4 + - claude-sonnet-4-6 + - claude-opus-4-6 + retrospective: + chain: + - ollama/qwen3-coder-30b-tuned + - claude-sonnet-4-6 + trainer: + chain: + - ollama/qwen3-coder-30b-tuned + - claude-sonnet-4-6 diff --git a/internal/config/models.go b/internal/config/models.go index 09b1263..8b3503b 100644 --- a/internal/config/models.go +++ b/internal/config/models.go @@ -7,9 +7,15 @@ import ( "gopkg.in/yaml.v3" ) +type skillChain struct { + Chain []string `yaml:"chain"` +} + type modelsFile struct { - Default string `yaml:"default"` - Skills map[string]string `yaml:"skills"` + Verifier string `yaml:"verifier"` + LlamaSwapURL string `yaml:"llama_swap_url"` + DefaultChain []string `yaml:"default_chain"` + Skills map[string]skillChain `yaml:"skills"` } type Models struct { @@ -28,16 +34,23 @@ func LoadModels(path string) (Models, error) { return Models{data: f}, nil } -// Resolve returns the model for a skill, respecting three-layer priority: -// 1. override (from MCP call) — highest -// 2. per-skill default from models.yaml -// 3. global default -func (m Models) Resolve(skill, override string) string { +// Verifier returns the model name to use for all local-tier output verification. +func (m Models) Verifier() string { return m.data.Verifier } + +// LlamaSwapURL returns the llama-swap base URL for warm-state probing. +func (m Models) LlamaSwapURL() string { return m.data.LlamaSwapURL } + +// ChainFor returns the ordered list of model names for a skill. +// If override is non-empty, returns a single-entry chain (no escalation). +// Falls back to default_chain when the skill has no explicit entry. +func (m Models) ChainFor(skill, override string) []string { if override != "" { - return override + return []string{override} } - if model, ok := m.data.Skills[skill]; ok { - return model + if sc, ok := m.data.Skills[skill]; ok && len(sc.Chain) > 0 { + return sc.Chain } - return m.data.Default + out := make([]string, len(m.data.DefaultChain)) + copy(out, m.data.DefaultChain) + return out } diff --git a/internal/config/models_test.go b/internal/config/models_test.go index 1657bf7..b37a525 100644 --- a/internal/config/models_test.go +++ b/internal/config/models_test.go @@ -10,35 +10,71 @@ import ( "github.com/stretchr/testify/require" ) -func TestModelsResolve(t *testing.T) { - yaml := ` -default: ollama/default-model +const testYAML = ` +verifier: claude-sonnet-4-6 +llama_swap_url: http://koala:8080 + +default_chain: + - ollama/qwen3-coder-30b-tuned + - claude-sonnet-4-6 + skills: - tdd: ollama/qwen3-coder-30b-tuned - review: ollama/devstral-tuned + review: + chain: + - ollama/devstral-tuned + - ollama/gemma4 + - claude-sonnet-4-6 + spec: + chain: + - ollama/phi4 + - claude-opus-4-6 ` + +func writeModels(t *testing.T, content string) string { + t.Helper() f := filepath.Join(t.TempDir(), "models.yaml") - require.NoError(t, os.WriteFile(f, []byte(yaml), 0644)) - - m, err := config.LoadModels(f) - require.NoError(t, err) - - assert.Equal(t, "ollama/qwen3-coder-30b-tuned", m.Resolve("tdd", "")) - assert.Equal(t, "ollama/devstral-tuned", m.Resolve("review", "")) - assert.Equal(t, "ollama/default-model", m.Resolve("unknown", "")) + require.NoError(t, os.WriteFile(f, []byte(content), 0644)) + return f } -func TestModelsOverride(t *testing.T) { - yaml := ` -default: ollama/default-model -skills: - tdd: ollama/qwen3-coder-30b-tuned -` - f := filepath.Join(t.TempDir(), "models.yaml") - require.NoError(t, os.WriteFile(f, []byte(yaml), 0644)) +func TestModelsVerifier(t *testing.T) { + m, err := config.LoadModels(writeModels(t, testYAML)) + require.NoError(t, err) + assert.Equal(t, "claude-sonnet-4-6", m.Verifier()) +} - m, err := config.LoadModels(f) +func TestModelsLlamaSwapURL(t *testing.T) { + m, err := config.LoadModels(writeModels(t, testYAML)) + require.NoError(t, err) + assert.Equal(t, "http://koala:8080", m.LlamaSwapURL()) +} + +func TestModelsChainForSkillOverride(t *testing.T) { + m, err := config.LoadModels(writeModels(t, testYAML)) require.NoError(t, err) - assert.Equal(t, "anthropic/claude-sonnet-4-6", m.Resolve("tdd", "anthropic/claude-sonnet-4-6")) + chain := m.ChainFor("review", "") + require.Len(t, chain, 3) + assert.Equal(t, "ollama/devstral-tuned", chain[0]) + assert.Equal(t, "ollama/gemma4", chain[1]) + assert.Equal(t, "claude-sonnet-4-6", chain[2]) +} + +func TestModelsChainForDefaultFallback(t *testing.T) { + m, err := config.LoadModels(writeModels(t, testYAML)) + require.NoError(t, err) + + chain := m.ChainFor("trainer", "") // not in skills map + require.Len(t, chain, 2) + assert.Equal(t, "ollama/qwen3-coder-30b-tuned", chain[0]) + assert.Equal(t, "claude-sonnet-4-6", chain[1]) +} + +func TestModelsChainForCallerOverride(t *testing.T) { + m, err := config.LoadModels(writeModels(t, testYAML)) + require.NoError(t, err) + + chain := m.ChainFor("review", "claude-opus-4-6") + require.Len(t, chain, 1) + assert.Equal(t, "claude-opus-4-6", chain[0]) }