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:
parent
f4caf29a35
commit
2c26a7fb9c
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
162
backend/new.go
162
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
2
justfile
2
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
|
||||
|
|
|
|||
Loading…
Reference in New Issue