refactor!: remove hardcoded default models, require explicit config

REMOVED all hardcoded default backend/model values from defaultModelsConfig.
Now ~/.codeagent/models.json is REQUIRED - missing config returns clear error
with example configuration.

BREAKING CHANGE: Users must configure ~/.codeagent/models.json before using
--agent or parallel tasks with agent: field.

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
This commit is contained in:
cexll
2026-01-26 17:47:21 +08:00
parent 3d76d46336
commit e9a8013c6f
9 changed files with 273 additions and 147 deletions

View File

@@ -3,6 +3,7 @@ package wrapper
import ( import (
"bytes" "bytes"
"os" "os"
"path/filepath"
"testing" "testing"
config "codeagent-wrapper/internal/config" config "codeagent-wrapper/internal/config"
@@ -29,6 +30,18 @@ func BenchmarkConfigParse_ParseArgs(b *testing.B) {
b.Setenv("HOME", home) b.Setenv("HOME", home)
b.Setenv("USERPROFILE", home) b.Setenv("USERPROFILE", home)
configDir := filepath.Join(home, ".codeagent")
if err := os.MkdirAll(configDir, 0o755); err != nil {
b.Fatal(err)
}
if err := os.WriteFile(filepath.Join(configDir, "models.json"), []byte(`{
"agents": {
"develop": { "backend": "codex", "model": "gpt-test" }
}
}`), 0o644); err != nil {
b.Fatal(err)
}
config.ResetModelsConfigCacheForTest() config.ResetModelsConfigCacheForTest()
b.Cleanup(config.ResetModelsConfigCacheForTest) b.Cleanup(config.ResetModelsConfigCacheForTest)

View File

@@ -254,7 +254,11 @@ func buildSingleConfig(cmd *cobra.Command, args []string, rawArgv []string, opts
var resolvedBackend, resolvedModel, resolvedPromptFile, resolvedReasoning string var resolvedBackend, resolvedModel, resolvedPromptFile, resolvedReasoning string
if agentName != "" { if agentName != "" {
var resolvedYolo bool var resolvedYolo bool
resolvedBackend, resolvedModel, resolvedPromptFile, resolvedReasoning, _, _, resolvedYolo = config.ResolveAgentConfig(agentName) var err error
resolvedBackend, resolvedModel, resolvedPromptFile, resolvedReasoning, _, _, resolvedYolo, err = config.ResolveAgentConfig(agentName)
if err != nil {
return nil, fmt.Errorf("failed to resolve agent %q: %w", agentName, err)
}
yolo = resolvedYolo yolo = resolvedYolo
} }

View File

@@ -1392,6 +1392,24 @@ func TestBackendParseArgs_PromptFileFlag(t *testing.T) {
func TestBackendParseArgs_PromptFileOverridesAgent(t *testing.T) { func TestBackendParseArgs_PromptFileOverridesAgent(t *testing.T) {
defer resetTestHooks() defer resetTestHooks()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Cleanup(config.ResetModelsConfigCacheForTest)
config.ResetModelsConfigCacheForTest()
configDir := filepath.Join(home, ".codeagent")
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join(configDir, "models.json"), []byte(`{
"agents": {
"develop": { "backend": "codex", "model": "gpt-test" }
}
}`), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
os.Args = []string{"codeagent-wrapper", "--prompt-file", "/tmp/custom.md", "--agent", "develop", "task"} os.Args = []string{"codeagent-wrapper", "--prompt-file", "/tmp/custom.md", "--agent", "develop", "task"}
cfg, err := parseArgs() cfg, err := parseArgs()
if err != nil { if err != nil {

View File

@@ -7,8 +7,6 @@ import (
"strings" "strings"
"sync" "sync"
ilogger "codeagent-wrapper/internal/logger"
"github.com/goccy/go-json" "github.com/goccy/go-json"
) )
@@ -35,80 +33,85 @@ type ModelsConfig struct {
Backends map[string]BackendConfig `json:"backends,omitempty"` Backends map[string]BackendConfig `json:"backends,omitempty"`
} }
var defaultModelsConfig = ModelsConfig{ var defaultModelsConfig = ModelsConfig{}
DefaultBackend: "opencode",
DefaultModel: "opencode/grok-code", const modelsConfigTildePath = "~/.codeagent/models.json"
Agents: map[string]AgentModelConfig{
"oracle": {Backend: "claude", Model: "claude-opus-4-5-20251101", PromptFile: "~/.claude/skills/omo/references/oracle.md", Description: "Technical advisor"}, const modelsConfigExample = `{
"librarian": {Backend: "claude", Model: "claude-sonnet-4-5-20250929", PromptFile: "~/.claude/skills/omo/references/librarian.md", Description: "Researcher"}, "default_backend": "codex",
"explore": {Backend: "opencode", Model: "opencode/grok-code", PromptFile: "~/.claude/skills/omo/references/explore.md", Description: "Code search"}, "default_model": "gpt-4.1",
"develop": {Backend: "codex", Model: "", PromptFile: "~/.claude/skills/omo/references/develop.md", Description: "Code development"}, "backends": {
"frontend-ui-ux-engineer": {Backend: "gemini", Model: "", PromptFile: "~/.claude/skills/omo/references/frontend-ui-ux-engineer.md", Description: "Frontend engineer"}, "codex": { "api_key": "..." },
"document-writer": {Backend: "gemini", Model: "", PromptFile: "~/.claude/skills/omo/references/document-writer.md", Description: "Documentation"}, "claude": { "api_key": "..." }
}, },
} "agents": {
"develop": {
"backend": "codex",
"model": "gpt-4.1",
"prompt_file": "~/.codeagent/prompts/develop.md",
"reasoning": "high",
"yolo": true
}
}
}`
var ( var (
modelsConfigOnce sync.Once modelsConfigOnce sync.Once
modelsConfigCached *ModelsConfig modelsConfigCached *ModelsConfig
modelsConfigErr error
) )
func modelsConfig() *ModelsConfig { func modelsConfig() (*ModelsConfig, error) {
modelsConfigOnce.Do(func() { modelsConfigOnce.Do(func() {
modelsConfigCached = loadModelsConfig() modelsConfigCached, modelsConfigErr = loadModelsConfig()
}) })
if modelsConfigCached == nil { return modelsConfigCached, modelsConfigErr
return &defaultModelsConfig
}
return modelsConfigCached
} }
func loadModelsConfig() *ModelsConfig { func modelsConfigPath() (string, error) {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil || strings.TrimSpace(home) == "" {
ilogger.LogWarn(fmt.Sprintf("Failed to resolve home directory for models config: %v; using defaults", err)) return "", fmt.Errorf("failed to resolve user home directory: %w", err)
return &defaultModelsConfig
} }
configDir := filepath.Clean(filepath.Join(home, ".codeagent")) configDir := filepath.Clean(filepath.Join(home, ".codeagent"))
configPath := filepath.Clean(filepath.Join(configDir, "models.json")) configPath := filepath.Clean(filepath.Join(configDir, "models.json"))
rel, err := filepath.Rel(configDir, configPath) rel, err := filepath.Rel(configDir, configPath)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return &defaultModelsConfig return "", fmt.Errorf("refusing to read models config outside %s: %s", configDir, configPath)
}
return configPath, nil
}
func modelsConfigHint(configPath string) string {
configPath = strings.TrimSpace(configPath)
if configPath == "" {
return fmt.Sprintf("Create %s with e.g.:\n%s", modelsConfigTildePath, modelsConfigExample)
}
return fmt.Sprintf("Create %s (resolved to %s) with e.g.:\n%s", modelsConfigTildePath, configPath, modelsConfigExample)
}
func loadModelsConfig() (*ModelsConfig, error) {
configPath, err := modelsConfigPath()
if err != nil {
return nil, fmt.Errorf("%w\n\n%s", err, modelsConfigHint(""))
} }
data, err := os.ReadFile(configPath) // #nosec G304 -- path is fixed under user home and validated to stay within configDir data, err := os.ReadFile(configPath) // #nosec G304 -- path is fixed under user home and validated to stay within configDir
if err != nil { if err != nil {
if !os.IsNotExist(err) { if os.IsNotExist(err) {
ilogger.LogWarn(fmt.Sprintf("Failed to read models config %s: %v; using defaults", configPath, err)) return nil, fmt.Errorf("models config not found: %s\n\n%s", configPath, modelsConfigHint(configPath))
} }
return &defaultModelsConfig return nil, fmt.Errorf("failed to read models config %s: %w\n\n%s", configPath, err, modelsConfigHint(configPath))
} }
var cfg ModelsConfig var cfg ModelsConfig
if err := json.Unmarshal(data, &cfg); err != nil { if err := json.Unmarshal(data, &cfg); err != nil {
ilogger.LogWarn(fmt.Sprintf("Failed to parse models config %s: %v; using defaults", configPath, err)) return nil, fmt.Errorf("failed to parse models config %s: %w\n\n%s", configPath, err, modelsConfigHint(configPath))
return &defaultModelsConfig
} }
cfg.DefaultBackend = strings.TrimSpace(cfg.DefaultBackend) cfg.DefaultBackend = strings.TrimSpace(cfg.DefaultBackend)
if cfg.DefaultBackend == "" {
cfg.DefaultBackend = defaultModelsConfig.DefaultBackend
}
cfg.DefaultModel = strings.TrimSpace(cfg.DefaultModel) cfg.DefaultModel = strings.TrimSpace(cfg.DefaultModel)
if cfg.DefaultModel == "" {
cfg.DefaultModel = defaultModelsConfig.DefaultModel
}
// Merge with defaults
for name, agent := range defaultModelsConfig.Agents {
if _, exists := cfg.Agents[name]; !exists {
if cfg.Agents == nil {
cfg.Agents = make(map[string]AgentModelConfig)
}
cfg.Agents[name] = agent
}
}
// Normalize backend keys so lookups can be case-insensitive. // Normalize backend keys so lookups can be case-insensitive.
if len(cfg.Backends) > 0 { if len(cfg.Backends) > 0 {
@@ -127,7 +130,7 @@ func loadModelsConfig() *ModelsConfig {
} }
} }
return &cfg return &cfg, nil
} }
func LoadDynamicAgent(name string) (AgentModelConfig, bool) { func LoadDynamicAgent(name string) (AgentModelConfig, bool) {
@@ -150,7 +153,10 @@ func LoadDynamicAgent(name string) (AgentModelConfig, bool) {
} }
func ResolveBackendConfig(backendName string) (baseURL, apiKey string) { func ResolveBackendConfig(backendName string) (baseURL, apiKey string) {
cfg := modelsConfig() cfg, err := modelsConfig()
if err != nil || cfg == nil {
return "", ""
}
resolved := resolveBackendConfig(cfg, backendName) resolved := resolveBackendConfig(cfg, backendName)
return strings.TrimSpace(resolved.BaseURL), strings.TrimSpace(resolved.APIKey) return strings.TrimSpace(resolved.BaseURL), strings.TrimSpace(resolved.APIKey)
} }
@@ -172,12 +178,30 @@ func resolveBackendConfig(cfg *ModelsConfig, backendName string) BackendConfig {
return BackendConfig{} return BackendConfig{}
} }
func resolveAgentConfig(agentName string) (backend, model, promptFile, reasoning, baseURL, apiKey string, yolo bool) { func resolveAgentConfig(agentName string) (backend, model, promptFile, reasoning, baseURL, apiKey string, yolo bool, err error) {
cfg := modelsConfig() if err := ValidateAgentName(agentName); err != nil {
return "", "", "", "", "", "", false, err
}
cfg, err := modelsConfig()
if err != nil {
return "", "", "", "", "", "", false, err
}
if cfg == nil {
return "", "", "", "", "", "", false, fmt.Errorf("models config is nil\n\n%s", modelsConfigHint(""))
}
if agent, ok := cfg.Agents[agentName]; ok { if agent, ok := cfg.Agents[agentName]; ok {
backend = strings.TrimSpace(agent.Backend) backend = strings.TrimSpace(agent.Backend)
if backend == "" { if backend == "" {
backend = cfg.DefaultBackend backend = strings.TrimSpace(cfg.DefaultBackend)
if backend == "" {
configPath, pathErr := modelsConfigPath()
if pathErr != nil {
return "", "", "", "", "", "", false, fmt.Errorf("agent %q has empty backend and default_backend is not set\n\n%s", agentName, modelsConfigHint(""))
}
return "", "", "", "", "", "", false, fmt.Errorf("agent %q has empty backend and default_backend is not set\n\n%s", agentName, modelsConfigHint(configPath))
}
} }
backendCfg := resolveBackendConfig(cfg, backend) backendCfg := resolveBackendConfig(cfg, backend)
@@ -190,31 +214,46 @@ func resolveAgentConfig(agentName string) (backend, model, promptFile, reasoning
apiKey = strings.TrimSpace(backendCfg.APIKey) apiKey = strings.TrimSpace(backendCfg.APIKey)
} }
return backend, strings.TrimSpace(agent.Model), agent.PromptFile, agent.Reasoning, baseURL, apiKey, agent.Yolo model = strings.TrimSpace(agent.Model)
if model == "" {
configPath, pathErr := modelsConfigPath()
if pathErr != nil {
return "", "", "", "", "", "", false, fmt.Errorf("agent %q has empty model; set agents.%s.model in %s\n\n%s", agentName, agentName, modelsConfigTildePath, modelsConfigHint(""))
}
return "", "", "", "", "", "", false, fmt.Errorf("agent %q has empty model; set agents.%s.model in %s\n\n%s", agentName, agentName, modelsConfigTildePath, modelsConfigHint(configPath))
}
return backend, model, agent.PromptFile, agent.Reasoning, baseURL, apiKey, agent.Yolo, nil
} }
if dynamic, ok := LoadDynamicAgent(agentName); ok { if dynamic, ok := LoadDynamicAgent(agentName); ok {
backend = cfg.DefaultBackend backend = strings.TrimSpace(cfg.DefaultBackend)
model = cfg.DefaultModel model = strings.TrimSpace(cfg.DefaultModel)
configPath, pathErr := modelsConfigPath()
if backend == "" || model == "" {
if pathErr != nil {
return "", "", "", "", "", "", false, fmt.Errorf("dynamic agent %q requires default_backend and default_model to be set in %s\n\n%s", agentName, modelsConfigTildePath, modelsConfigHint(""))
}
return "", "", "", "", "", "", false, fmt.Errorf("dynamic agent %q requires default_backend and default_model to be set in %s\n\n%s", agentName, modelsConfigTildePath, modelsConfigHint(configPath))
}
backendCfg := resolveBackendConfig(cfg, backend) backendCfg := resolveBackendConfig(cfg, backend)
baseURL = strings.TrimSpace(backendCfg.BaseURL) baseURL = strings.TrimSpace(backendCfg.BaseURL)
apiKey = strings.TrimSpace(backendCfg.APIKey) apiKey = strings.TrimSpace(backendCfg.APIKey)
return backend, model, dynamic.PromptFile, "", baseURL, apiKey, false return backend, model, dynamic.PromptFile, "", baseURL, apiKey, false, nil
} }
backend = cfg.DefaultBackend configPath, pathErr := modelsConfigPath()
model = cfg.DefaultModel if pathErr != nil {
backendCfg := resolveBackendConfig(cfg, backend) return "", "", "", "", "", "", false, fmt.Errorf("agent %q not found in %s\n\n%s", agentName, modelsConfigTildePath, modelsConfigHint(""))
baseURL = strings.TrimSpace(backendCfg.BaseURL) }
apiKey = strings.TrimSpace(backendCfg.APIKey) return "", "", "", "", "", "", false, fmt.Errorf("agent %q not found in %s\n\n%s", agentName, modelsConfigTildePath, modelsConfigHint(configPath))
return backend, model, "", "", baseURL, apiKey, false
} }
func ResolveAgentConfig(agentName string) (backend, model, promptFile, reasoning, baseURL, apiKey string, yolo bool) { func ResolveAgentConfig(agentName string) (backend, model, promptFile, reasoning, baseURL, apiKey string, yolo bool, err error) {
return resolveAgentConfig(agentName) return resolveAgentConfig(agentName)
} }
func ResetModelsConfigCacheForTest() { func ResetModelsConfigCacheForTest() {
modelsConfigCached = nil modelsConfigCached = nil
modelsConfigErr = nil
modelsConfigOnce = sync.Once{} modelsConfigOnce = sync.Once{}
} }

View File

@@ -3,78 +3,43 @@ package config
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
func TestResolveAgentConfig_Defaults(t *testing.T) { func TestResolveAgentConfig_NoConfig_ReturnsHelpfulError(t *testing.T) {
home := t.TempDir() home := t.TempDir()
t.Setenv("HOME", home) t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home) t.Setenv("USERPROFILE", home)
t.Cleanup(ResetModelsConfigCacheForTest) t.Cleanup(ResetModelsConfigCacheForTest)
ResetModelsConfigCacheForTest() ResetModelsConfigCacheForTest()
// Test that default agents resolve correctly without config file _, _, _, _, _, _, _, err := ResolveAgentConfig("develop")
tests := []struct { if err == nil {
agent string t.Fatalf("expected error, got nil")
wantBackend string
wantModel string
wantPromptFile string
}{
{"oracle", "claude", "claude-opus-4-5-20251101", "~/.claude/skills/omo/references/oracle.md"},
{"librarian", "claude", "claude-sonnet-4-5-20250929", "~/.claude/skills/omo/references/librarian.md"},
{"explore", "opencode", "opencode/grok-code", "~/.claude/skills/omo/references/explore.md"},
{"frontend-ui-ux-engineer", "gemini", "", "~/.claude/skills/omo/references/frontend-ui-ux-engineer.md"},
{"document-writer", "gemini", "", "~/.claude/skills/omo/references/document-writer.md"},
} }
msg := err.Error()
for _, tt := range tests { if !strings.Contains(msg, modelsConfigTildePath) {
t.Run(tt.agent, func(t *testing.T) { t.Fatalf("error should mention %s, got: %s", modelsConfigTildePath, msg)
backend, model, promptFile, _, _, _, _ := resolveAgentConfig(tt.agent)
if backend != tt.wantBackend {
t.Errorf("backend = %q, want %q", backend, tt.wantBackend)
} }
if model != tt.wantModel { if !strings.Contains(msg, filepath.Join(home, ".codeagent", "models.json")) {
t.Errorf("model = %q, want %q", model, tt.wantModel) t.Fatalf("error should mention resolved config path, got: %s", msg)
} }
if promptFile != tt.wantPromptFile { if !strings.Contains(msg, "\"agents\"") {
t.Errorf("promptFile = %q, want %q", promptFile, tt.wantPromptFile) t.Fatalf("error should include example config, got: %s", msg)
}
})
}
}
func TestResolveAgentConfig_UnknownAgent(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Cleanup(ResetModelsConfigCacheForTest)
ResetModelsConfigCacheForTest()
backend, model, promptFile, _, _, _, _ := resolveAgentConfig("unknown-agent")
if backend != "opencode" {
t.Errorf("unknown agent backend = %q, want %q", backend, "opencode")
}
if model != "opencode/grok-code" {
t.Errorf("unknown agent model = %q, want %q", model, "opencode/grok-code")
}
if promptFile != "" {
t.Errorf("unknown agent promptFile = %q, want empty", promptFile)
} }
} }
func TestLoadModelsConfig_NoFile(t *testing.T) { func TestLoadModelsConfig_NoFile(t *testing.T) {
home := "/nonexistent/path/that/does/not/exist" home := t.TempDir()
t.Setenv("HOME", home) t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home) t.Setenv("USERPROFILE", home)
t.Cleanup(ResetModelsConfigCacheForTest) t.Cleanup(ResetModelsConfigCacheForTest)
ResetModelsConfigCacheForTest() ResetModelsConfigCacheForTest()
cfg := loadModelsConfig() _, err := loadModelsConfig()
if cfg.DefaultBackend != "opencode" { if err == nil {
t.Errorf("DefaultBackend = %q, want %q", cfg.DefaultBackend, "opencode") t.Fatalf("expected error, got nil")
}
if len(cfg.Agents) != 6 {
t.Errorf("len(Agents) = %d, want 6", len(cfg.Agents))
} }
} }
@@ -119,7 +84,10 @@ func TestLoadModelsConfig_WithFile(t *testing.T) {
t.Cleanup(ResetModelsConfigCacheForTest) t.Cleanup(ResetModelsConfigCacheForTest)
ResetModelsConfigCacheForTest() ResetModelsConfigCacheForTest()
cfg := loadModelsConfig() cfg, err := loadModelsConfig()
if err != nil {
t.Fatalf("loadModelsConfig: %v", err)
}
if cfg.DefaultBackend != "claude" { if cfg.DefaultBackend != "claude" {
t.Errorf("DefaultBackend = %q, want %q", cfg.DefaultBackend, "claude") t.Errorf("DefaultBackend = %q, want %q", cfg.DefaultBackend, "claude")
@@ -140,9 +108,8 @@ func TestLoadModelsConfig_WithFile(t *testing.T) {
} }
} }
// Check that defaults are merged if _, ok := cfg.Agents["oracle"]; ok {
if _, ok := cfg.Agents["oracle"]; !ok { t.Error("oracle should not be present without explicit config")
t.Error("default agent oracle should be merged")
} }
baseURL, apiKey := ResolveBackendConfig("claude") baseURL, apiKey := ResolveBackendConfig("claude")
@@ -153,7 +120,10 @@ func TestLoadModelsConfig_WithFile(t *testing.T) {
t.Errorf("ResolveBackendConfig(apiKey) = %q, want %q", apiKey, "backend-key") t.Errorf("ResolveBackendConfig(apiKey) = %q, want %q", apiKey, "backend-key")
} }
backend, model, _, _, agentBaseURL, agentAPIKey, _ := ResolveAgentConfig("custom-agent") backend, model, _, _, agentBaseURL, agentAPIKey, _, err := ResolveAgentConfig("custom-agent")
if err != nil {
t.Fatalf("ResolveAgentConfig(custom-agent): %v", err)
}
if backend != "codex" { if backend != "codex" {
t.Errorf("ResolveAgentConfig(backend) = %q, want %q", backend, "codex") t.Errorf("ResolveAgentConfig(backend) = %q, want %q", backend, "codex")
} }
@@ -183,12 +153,26 @@ func TestResolveAgentConfig_DynamicAgent(t *testing.T) {
t.Fatalf("WriteFile: %v", err) t.Fatalf("WriteFile: %v", err)
} }
backend, model, promptFile, _, _, _, _ := resolveAgentConfig("sarsh") configDir := filepath.Join(home, ".codeagent")
if backend != "opencode" { if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Errorf("backend = %q, want %q", backend, "opencode") t.Fatalf("MkdirAll: %v", err)
} }
if model != "opencode/grok-code" { if err := os.WriteFile(filepath.Join(configDir, "models.json"), []byte(`{
t.Errorf("model = %q, want %q", model, "opencode/grok-code") "default_backend": "codex",
"default_model": "gpt-test"
}`), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
backend, model, promptFile, _, _, _, _, err := ResolveAgentConfig("sarsh")
if err != nil {
t.Fatalf("ResolveAgentConfig(sarsh): %v", err)
}
if backend != "codex" {
t.Errorf("backend = %q, want %q", backend, "codex")
}
if model != "gpt-test" {
t.Errorf("model = %q, want %q", model, "gpt-test")
} }
if promptFile != "~/.codeagent/agents/sarsh.md" { if promptFile != "~/.codeagent/agents/sarsh.md" {
t.Errorf("promptFile = %q, want %q", promptFile, "~/.codeagent/agents/sarsh.md") t.Errorf("promptFile = %q, want %q", promptFile, "~/.codeagent/agents/sarsh.md")
@@ -213,9 +197,66 @@ func TestLoadModelsConfig_InvalidJSON(t *testing.T) {
t.Cleanup(ResetModelsConfigCacheForTest) t.Cleanup(ResetModelsConfigCacheForTest)
ResetModelsConfigCacheForTest() ResetModelsConfigCacheForTest()
cfg := loadModelsConfig() _, err := loadModelsConfig()
// Should fall back to defaults if err == nil {
if cfg.DefaultBackend != "opencode" { t.Fatalf("expected error, got nil")
t.Errorf("invalid JSON should fallback, got DefaultBackend = %q", cfg.DefaultBackend) }
}
func TestResolveAgentConfig_UnknownAgent_ReturnsError(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Cleanup(ResetModelsConfigCacheForTest)
ResetModelsConfigCacheForTest()
configDir := filepath.Join(home, ".codeagent")
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join(configDir, "models.json"), []byte(`{
"default_backend": "codex",
"default_model": "gpt-test",
"agents": {
"develop": { "backend": "codex", "model": "gpt-test" }
}
}`), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
_, _, _, _, _, _, _, err := ResolveAgentConfig("unknown-agent")
if err == nil {
t.Fatalf("expected error, got nil")
}
if !strings.Contains(err.Error(), "unknown-agent") {
t.Fatalf("error should mention agent name, got: %s", err.Error())
}
}
func TestResolveAgentConfig_EmptyModel_ReturnsError(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Cleanup(ResetModelsConfigCacheForTest)
ResetModelsConfigCacheForTest()
configDir := filepath.Join(home, ".codeagent")
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join(configDir, "models.json"), []byte(`{
"agents": {
"bad-agent": { "backend": "codex", "model": " " }
}
}`), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
_, _, _, _, _, _, _, err := ResolveAgentConfig("bad-agent")
if err == nil {
t.Fatalf("expected error, got nil")
}
if !strings.Contains(strings.ToLower(err.Error()), "empty model") {
t.Fatalf("error should mention empty model, got: %s", err.Error())
} }
} }

View File

@@ -46,7 +46,10 @@ func TestEnvInjectionWithAgent(t *testing.T) {
defer config.ResetModelsConfigCacheForTest() defer config.ResetModelsConfigCacheForTest()
// Test ResolveAgentConfig // Test ResolveAgentConfig
agentBackend, model, _, _, baseURL, apiKey, _ := config.ResolveAgentConfig("test-agent") agentBackend, model, _, _, baseURL, apiKey, _, err := config.ResolveAgentConfig("test-agent")
if err != nil {
t.Fatalf("ResolveAgentConfig: %v", err)
}
t.Logf("ResolveAgentConfig: backend=%q, model=%q, baseURL=%q, apiKey=%q", t.Logf("ResolveAgentConfig: backend=%q, model=%q, baseURL=%q, apiKey=%q",
agentBackend, model, baseURL, apiKey) agentBackend, model, baseURL, apiKey)
@@ -118,7 +121,10 @@ func TestEnvInjectionLogic(t *testing.T) {
// Step 2: If agent specified, get agent config // Step 2: If agent specified, get agent config
if agentName != "" { if agentName != "" {
agentBackend, _, _, _, agentBaseURL, agentAPIKey, _ := config.ResolveAgentConfig(agentName) agentBackend, _, _, _, agentBaseURL, agentAPIKey, _, err := config.ResolveAgentConfig(agentName)
if err != nil {
t.Fatalf("ResolveAgentConfig(%q): %v", agentName, err)
}
t.Logf("Step 2 - ResolveAgentConfig(%q): backend=%q, baseURL=%q, apiKey=%q", t.Logf("Step 2 - ResolveAgentConfig(%q): backend=%q, baseURL=%q, apiKey=%q",
agentName, agentBackend, agentBaseURL, agentAPIKey) agentName, agentBackend, agentBaseURL, agentAPIKey)

View File

@@ -1060,11 +1060,13 @@ func RunCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe
if envBackend != nil { if envBackend != nil {
baseURL, apiKey := config.ResolveBackendConfig(cfg.Backend) baseURL, apiKey := config.ResolveBackendConfig(cfg.Backend)
if agentName := strings.TrimSpace(taskSpec.Agent); agentName != "" { if agentName := strings.TrimSpace(taskSpec.Agent); agentName != "" {
agentBackend, _, _, _, agentBaseURL, agentAPIKey, _ := config.ResolveAgentConfig(agentName) agentBackend, _, _, _, agentBaseURL, agentAPIKey, _, err := config.ResolveAgentConfig(agentName)
if err == nil {
if strings.EqualFold(strings.TrimSpace(agentBackend), strings.TrimSpace(cfg.Backend)) { if strings.EqualFold(strings.TrimSpace(agentBackend), strings.TrimSpace(cfg.Backend)) {
baseURL, apiKey = agentBaseURL, agentAPIKey baseURL, apiKey = agentBaseURL, agentAPIKey
} }
} }
}
if injected := envBackend.Env(baseURL, apiKey); len(injected) > 0 { if injected := envBackend.Env(baseURL, apiKey); len(injected) > 0 {
cmd.SetEnv(injected) cmd.SetEnv(injected)
// Log injected env vars with masked API keys (to file and stderr) // Log injected env vars with masked API keys (to file and stderr)

View File

@@ -96,7 +96,10 @@ func ParseParallelConfig(data []byte) (*ParallelConfig, error) {
if err := config.ValidateAgentName(task.Agent); err != nil { if err := config.ValidateAgentName(task.Agent); err != nil {
return nil, fmt.Errorf("task block #%d invalid agent name: %w", taskIndex, err) return nil, fmt.Errorf("task block #%d invalid agent name: %w", taskIndex, err)
} }
backend, model, promptFile, reasoning, _, _, _ := config.ResolveAgentConfig(task.Agent) backend, model, promptFile, reasoning, _, _, _, err := config.ResolveAgentConfig(task.Agent)
if err != nil {
return nil, fmt.Errorf("task block #%d failed to resolve agent %q: %w", taskIndex, task.Agent, err)
}
if task.Backend == "" { if task.Backend == "" {
task.Backend = backend task.Backend = backend
} }

View File

@@ -158,7 +158,7 @@ EOF
## ~/.codeagent/models.json Configuration ## ~/.codeagent/models.json Configuration
Optional. Uses codeagent-wrapper built-in config by default. To customize: Required when using `agent:` in parallel tasks or `--agent`. Create `~/.codeagent/models.json` to configure agent → backend/model mappings:
```json ```json
{ {