diff --git a/backend/config.go b/backend/config.go new file mode 100644 index 0000000..4214ceb --- /dev/null +++ b/backend/config.go @@ -0,0 +1,88 @@ +package backend + +import ( + "bufio" + "os" + "strings" + + "ollie/paths" +) + +// BackendConfig holds the per-backend configuration from backends.conf. +type BackendConfig struct { + Key string // API key / token + URL string // base URL override (empty = use backend's default) + Model string // default model override + Host string // for ollama: host address +} + +// configFile is the parsed contents of backends.conf: section name → key/value pairs. +type configFile map[string]BackendConfig + +// loadConfig reads ~/.config/ollie/backends.conf. +// Returns an empty map (not nil) if the file doesn't exist or is unreadable. +func loadConfig() configFile { + return parseConfigFile(paths.CfgDir() + "/backends.conf") +} + +// parseConfigFile parses an INI-style config file. +// Format: +// +// [section] +// key = value +// +// Lines starting with # are comments. Blank lines are ignored. +func parseConfigFile(path string) configFile { + f, err := os.Open(path) + if err != nil { + return configFile{} + } + defer f.Close() + + cfg := configFile{} + section := "" + + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + // Section header. + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + section = strings.TrimSpace(line[1 : len(line)-1]) + if _, ok := cfg[section]; !ok { + cfg[section] = BackendConfig{} + } + continue + } + // Key = value. + if section == "" { + continue // ignore keys before first section + } + k, v, ok := strings.Cut(line, "=") + if !ok { + continue + } + k = strings.TrimSpace(k) + v = strings.TrimSpace(v) + // Strip surrounding quotes. + if len(v) >= 2 && (v[0] == '"' && v[len(v)-1] == '"' || v[0] == '\'' && v[len(v)-1] == '\'') { + v = v[1 : len(v)-1] + } + + bc := cfg[section] + switch k { + case "key": + bc.Key = v + case "url": + bc.URL = v + case "model": + bc.Model = v + case "host": + bc.Host = v + } + cfg[section] = bc + } + return cfg +} diff --git a/backend/gemini.go b/backend/gemini.go index adacc73..21a87f9 100644 --- a/backend/gemini.go +++ b/backend/gemini.go @@ -1,7 +1,5 @@ package backend -import "os" - const ( geminiBaseURL = "https://generativelanguage.googleapis.com/v1beta/openai/" geminiDefaultModel = "gemini-2.5-flash-preview-05-20" @@ -20,10 +18,4 @@ func NewGemini(apiKey string) (*OpenAIBackend, error) { } // geminiKey returns the API key from OLLIE_GEMINI_KEY, falling back to -// OLLIE_OPENAI_KEY so existing env files that set the generic key still work. -func geminiKey() string { - if k := os.Getenv("OLLIE_GEMINI_KEY"); k != "" { - return k - } - return os.Getenv("OLLIE_OPENAI_KEY") -} + diff --git a/backend/new.go b/backend/new.go index 18a04d6..0a8f0c8 100644 --- a/backend/new.go +++ b/backend/new.go @@ -69,38 +69,136 @@ func newFromEnv(envFile string) (Backend, error) { } func newBackend(which string) (Backend, error) { + cfg := loadConfig() + bc := cfg[which] + switch which { case "ollama": - return NewOllama(os.Getenv("OLLAMA_HOST")) - case "openai", "openrouter": - url := os.Getenv("OLLIE_OPENAI_URL") - key := os.Getenv("OLLIE_OPENAI_KEY") - if key == "" { - return nil, fmt.Errorf("OLLIE_OPENAI_KEY is required for %s backend", which) + host := bc.Host + if host == "" { + host = os.Getenv("OLLAMA_HOST") } - return NewOpenAI(openAIName(which, url), url, key) + b, err := NewOllama(host) + if err != nil { + return nil, err + } + if bc.Model != "" { + b.SetModel(bc.Model) + } + return b, nil + + case "openai": + url := bc.URL + if url == "" { + url = os.Getenv("OLLIE_OPENAI_URL") + } + key := bc.Key + if key == "" { + key = os.Getenv("OLLIE_OPENAI_KEY") + } + if key == "" { + return nil, fmt.Errorf("openai: key required (backends.conf [openai] key= or OLLIE_OPENAI_KEY)") + } + b, err := NewOpenAI("openai", url, key) + if err != nil { + return nil, err + } + if bc.Model != "" { + b.SetModel(bc.Model) + } + return b, nil + + case "openrouter": + url := bc.URL + if url == "" { + url = os.Getenv("OLLIE_OPENAI_URL") + } + if url == "" { + url = "https://openrouter.ai/api" + } + key := bc.Key + if key == "" { + key = os.Getenv("OLLIE_OPENAI_KEY") + } + if key == "" { + return nil, fmt.Errorf("openrouter: key required (backends.conf [openrouter] key= or OLLIE_OPENAI_KEY)") + } + b, err := NewOpenAI("openrouter", url, key) + if err != nil { + return nil, err + } + if bc.Model != "" { + b.SetModel(bc.Model) + } + return b, nil + case "anthropic": - key := os.Getenv("OLLIE_ANTHROPIC_KEY") + key := bc.Key if key == "" { - return nil, fmt.Errorf("OLLIE_ANTHROPIC_KEY is required for anthropic backend") + key = os.Getenv("OLLIE_ANTHROPIC_KEY") } - return NewAnthropic(key) + if key == "" { + return nil, fmt.Errorf("anthropic: key required (backends.conf [anthropic] key= or OLLIE_ANTHROPIC_KEY)") + } + b, err := NewAnthropic(key) + if err != nil { + return nil, err + } + if bc.Model != "" { + b.SetModel(bc.Model) + } + return b, nil + case "copilot": - token := os.Getenv("OLLIE_COPILOT_TOKEN") + token := bc.Key if token == "" { - return nil, fmt.Errorf("OLLIE_COPILOT_TOKEN is required for copilot backend") + token = os.Getenv("OLLIE_COPILOT_TOKEN") } - return NewCopilot(token) + if token == "" { + return nil, fmt.Errorf("copilot: key required (backends.conf [copilot] key= or OLLIE_COPILOT_TOKEN)") + } + b, err := NewCopilot(token) + if err != nil { + return nil, err + } + if bc.Model != "" { + b.SetModel(bc.Model) + } + return b, nil + case "kiro", "codewhisperer": - return NewCodeWhisperer(os.Getenv("OLLIE_KIRO_TOKEN")) - case "gemini": - key := geminiKey() - if key == "" { - return nil, fmt.Errorf("OLLIE_GEMINI_KEY is required for gemini backend") + token := bc.Key + if token == "" { + token = os.Getenv("OLLIE_KIRO_TOKEN") } - return NewGemini(key) + b, err := NewCodeWhisperer(token) + if err != nil { + return nil, err + } + if bc.Model != "" { + b.SetModel(bc.Model) + } + return b, nil + + case "gemini": + key := bc.Key + if key == "" { + key = os.Getenv("OLLIE_GEMINI_KEY") + } + if key == "" { + return nil, fmt.Errorf("gemini: key required (backends.conf [gemini] key= or OLLIE_GEMINI_KEY)") + } + b, err := NewGemini(key) + if err != nil { + return nil, err + } + if bc.Model != "" { + b.SetModel(bc.Model) + } + return b, nil + default: - return nil, fmt.Errorf("unknown OLLIE_BACKEND %q (supported: ollama, openai, openrouter, anthropic, copilot, kiro, gemini)", which) + return nil, fmt.Errorf("unknown backend %q (supported: ollama, openai, openrouter, anthropic, copilot, kiro, gemini)", which) } } @@ -108,27 +206,3 @@ func newBackend(which string) (Backend, error) { func Backends() []string { return []string{"ollama", "openai", "openrouter", "anthropic", "copilot", "kiro", "gemini"} } - -// openAIName derives a short backend label from the OLLIE_BACKEND value and -// the base URL, so openai-compatible endpoints self-identify correctly. -func openAIName(which, url string) string { - url = strings.ToLower(url) - switch { - case strings.Contains(url, "openrouter"): - return "openrouter" - case strings.Contains(url, "together"): - return "together" - case strings.Contains(url, "groq"): - return "groq" - case strings.Contains(url, "mistral"): - return "mistral" - case strings.Contains(url, "anthropic"): - return "anthropic" - case strings.Contains(url, "generativelanguage") || strings.Contains(url, "google"): - return "gemini" - case strings.Contains(url, "localhost") || strings.Contains(url, "127.0.0.1"): - return "local" - default: - return which - } -} diff --git a/backend/new_test.go b/backend/new_test.go index 312a741..989f55f 100644 --- a/backend/new_test.go +++ b/backend/new_test.go @@ -176,30 +176,6 @@ func TestNewFromEnv_EnvOverridesFile(t *testing.T) { } } -func TestOpenAIName(t *testing.T) { - tests := []struct { - which, url, want string - }{ - {"openai", "https://openrouter.ai/api", "openrouter"}, - {"openai", "https://api.together.xyz", "together"}, - {"openai", "https://api.groq.com", "groq"}, - {"openai", "https://api.mistral.ai", "mistral"}, - {"openai", "https://api.anthropic.com", "anthropic"}, - {"openai", "http://localhost:8080", "local"}, - {"openai", "http://127.0.0.1:1234", "local"}, - {"openai", "https://api.openai.com", "openai"}, - {"openrouter", "", "openrouter"}, - {"openai", "", "openai"}, - // case insensitive - {"openai", "https://API.GROQ.COM", "groq"}, - } - for _, tt := range tests { - if got := openAIName(tt.which, tt.url); got != tt.want { - t.Errorf("openAIName(%q, %q) = %q; want %q", tt.which, tt.url, got, tt.want) - } - } -} - func TestBackends(t *testing.T) { bs := Backends() if len(bs) != 7 { diff --git a/data/backends.conf b/data/backends.conf new file mode 100644 index 0000000..07839fa --- /dev/null +++ b/data/backends.conf @@ -0,0 +1,35 @@ +# Ollie backend configuration. +# Each section configures a backend. The session selects which backend to use +# via OLLIE_BACKEND env var or backend= parameter. +# +# Fields: +# key = API key or token +# url = base URL override (optional; each backend has a sensible default) +# model = default model (optional; overridden by session model= parameter) +# host = host address (ollama only) + +[openrouter] +# key = sk-or-v1-... +# model = qwen/qwen3-235b-a22b + +[openai] +# key = sk-... +# model = gpt-4.1 + +[anthropic] +# key = sk-ant-... +# model = claude-sonnet-4-20250514 + +[ollama] +# host = http://localhost:11434 +# model = qwen3:latest + +[gemini] +# key = ... +# model = gemini-2.5-flash-preview-05-20 + +[copilot] +# key = ... + +[kiro] +# key = (auto-detected from Kiro CLI if unset) diff --git a/justfile b/justfile index 660c6b9..1b99b80 100644 --- a/justfile +++ b/justfile @@ -90,6 +90,8 @@ install-el: install-data: @echo === Installing data === + # Backend config (only install template if user hasn't created one yet) + @test -f {{cfg}}/backends.conf || install -m600 data/backends.conf {{cfg}}/backends.conf # Agents mkdir -p {{cfg}}/agents install -m644 data/agents/copilot.json {{cfg}}/agents/copilot.json