backend: add backends.conf for per-backend configuration

Each backend is now configured via ~/.config/ollie/backends.conf:

    [openrouter]
    key = sk-or-v1-...
    model = qwen/qwen3-235b-a22b

    [anthropic]
    key = sk-ant-...

Each backend knows its own default URL (openrouter → openrouter.ai/api,
anthropic → api.anthropic.com, etc). Only key and optional model needed.

Env vars (OLLIE_OPENAI_KEY, OLLIE_ANTHROPIC_KEY, etc) still work as
fallback for backward compatibility, but backends.conf is the canonical
config path going forward.

- Delete openAIName() (URL no longer determines backend identity)
- Delete geminiKey() (config handles fallback)
- Install template with mode 600 (contains API keys)
- Only install template if user hasn't created their own
This commit is contained in:
Ollie Agent 2026-08-08 16:25:47 +02:00
parent f4caf29a35
commit 2c26a7fb9c
6 changed files with 244 additions and 77 deletions

88
backend/config.go Normal file
View File

@ -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
}

View File

@ -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")
}

View File

@ -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
}
}

View File

@ -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 {

35
data/backends.conf Normal file
View File

@ -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)

View File

@ -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