diff --git a/config/models.yaml b/config/models.yaml new file mode 100644 index 0000000..cc3d63c --- /dev/null +++ b/config/models.yaml @@ -0,0 +1,10 @@ +# 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 + +skills: + tdd: ollama/qwen3-coder-30b-tuned + review: ollama/devstral-tuned + debug: ollama/deepseek-r1-tuned diff --git a/internal/config/models.go b/internal/config/models.go new file mode 100644 index 0000000..09b1263 --- /dev/null +++ b/internal/config/models.go @@ -0,0 +1,43 @@ +package config + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +type modelsFile struct { + Default string `yaml:"default"` + Skills map[string]string `yaml:"skills"` +} + +type Models struct { + data modelsFile +} + +func LoadModels(path string) (Models, error) { + raw, err := os.ReadFile(path) + if err != nil { + return Models{}, fmt.Errorf("load models: %w", err) + } + var f modelsFile + if err := yaml.Unmarshal(raw, &f); err != nil { + return Models{}, fmt.Errorf("parse models: %w", err) + } + 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 { + if override != "" { + return override + } + if model, ok := m.data.Skills[skill]; ok { + return model + } + return m.data.Default +} diff --git a/internal/config/models_test.go b/internal/config/models_test.go new file mode 100644 index 0000000..1657bf7 --- /dev/null +++ b/internal/config/models_test.go @@ -0,0 +1,44 @@ +package config_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/mathiasbq/supervisor/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestModelsResolve(t *testing.T) { + yaml := ` +default: ollama/default-model +skills: + tdd: ollama/qwen3-coder-30b-tuned + review: ollama/devstral-tuned +` + 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", "")) +} + +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)) + + m, err := config.LoadModels(f) + require.NoError(t, err) + + assert.Equal(t, "anthropic/claude-sonnet-4-6", m.Resolve("tdd", "anthropic/claude-sonnet-4-6")) +}