config.go

v0.2.0
Doc Versions Source
1
package config
2
3
import (
4
	"errors"
5
	"fmt"
6
	"os"
7
	"path/filepath"
8
9
	"sourcecraft.dev/bigbes/claudio/provider"
10
	"gopkg.in/yaml.v3"
11
)
12
13
// providerKeys lists all known provider identifiers for seeding default config.
14
var providerKeys = []string{"deepseek", "zai", "minimax", "kimi", "copilot", "nvidia", "openrouter", "ollama", "ollama-cloud", "vkproxy"}
15
16
const appName = "claudio"
17
18
// ProviderConfig holds per-provider user settings.
19
// For built-in providers only api_key is needed.
20
// For custom providers, set base_url + model + compat to define one entirely in YAML.
21
type ProviderConfig struct {
22
	APIKey        string            `yaml:"api_key"`
23
	Name          string            `yaml:"name,omitempty"`
24
	Description   string            `yaml:"description,omitempty"`
25
	BaseURL       string            `yaml:"base_url,omitempty"`
26
	Model         string            `yaml:"model,omitempty"`
27
	SmallModel    string            `yaml:"small_model,omitempty"`
28
	Compat        string            `yaml:"compat,omitempty"`         // "anthropic" or "openai"
29
	Proxy         string            `yaml:"proxy,omitempty"`           // e.g. "socks5://127.0.0.1:1080"
30
	ClaudeProxy   string            `yaml:"claude_proxy,omitempty"`   // HTTP(S) proxy for Claude Code (it doesn't support SOCKS5)
31
	Plan          string            `yaml:"plan,omitempty"`           // plan key for providers with multiple plans (e.g. "api", "coding")
32
	ContextWindow int               `yaml:"context_window,omitempty"` // model context window in tokens
33
	Env           map[string]string `yaml:"env,omitempty"`            // extra env vars to set at launch
34
	Hidden        bool              `yaml:"hidden,omitempty"`         // hide from the main menu
35
}
36
37
// Config is the top-level configuration.
38
type Config struct {
39
	ActiveProvider string                    `yaml:"active_provider"`
40
	Proxy          string                    `yaml:"proxy,omitempty"`       // global proxy for upstream requests, e.g. "socks5://127.0.0.1:1080"
41
	ClaudeProxy    string                    `yaml:"claude_proxy,omitempty"` // HTTP(S) proxy for Claude Code (it doesn't support SOCKS5)
42
	Providers      map[string]ProviderConfig `yaml:"providers"`
43
}
44
45
// Path returns the config file path following XDG Base Directory Specification.
46
func Path() string {
47
	dir := os.Getenv("XDG_CONFIG_HOME")
48
	if dir == "" {
49
		home, _ := os.UserHomeDir()
50
		dir = filepath.Join(home, ".config")
51
	}
52
	return filepath.Join(dir, appName, "config.yaml")
53
}
54
55
// Load reads the config from disk.
56
// If the file doesn't exist, it creates a default config with all known providers
57
// (empty API keys) and writes it to disk as a starting template.
58
func Load() (*Config, error) {
59
	data, err := os.ReadFile(Path())
60
	if err != nil {
61
		if errors.Is(err, os.ErrNotExist) {
62
			return createDefault()
63
		}
64
		return nil, err
65
	}
66
67
	cfg := &Config{
68
		Providers: make(map[string]ProviderConfig),
69
	}
70
	if err := yaml.Unmarshal(data, cfg); err != nil {
71
		return nil, err
72
	}
73
	if cfg.Providers == nil {
74
		cfg.Providers = make(map[string]ProviderConfig)
75
	}
76
77
	// Migrate legacy "zai-coding" → "zai" with plan=coding.
78
	if zc, ok := cfg.Providers["zai-coding"]; ok {
79
		zai := cfg.Providers["zai"]
80
		if zai.APIKey == "" && zc.APIKey != "" {
81
			zai.APIKey = zc.APIKey
82
		}
83
		if zai.Plan == "" {
84
			zai.Plan = "coding"
85
		}
86
		if zc.Model != "" && zai.Model == "" {
87
			zai.Model = zc.Model
88
		}
89
		cfg.Providers["zai"] = zai
90
		delete(cfg.Providers, "zai-coding")
91
		if cfg.ActiveProvider == "zai-coding" {
92
			cfg.ActiveProvider = "zai"
93
		}
94
	}
95
96
	return cfg, nil
97
}
98
99
// createDefault seeds and persists a config with all known providers.
100
func createDefault() (*Config, error) {
101
	cfg := &Config{
102
		Providers: make(map[string]ProviderConfig),
103
	}
104
	for _, key := range providerKeys {
105
		cfg.Providers[key] = ProviderConfig{}
106
	}
107
	if err := Save(cfg); err != nil {
108
		return nil, fmt.Errorf("creating default config: %w", err)
109
	}
110
	return cfg, nil
111
}
112
113
// ResolveProvider builds a provider.Provider for the given key by merging
114
// the built-in registry entry (if any) with config overrides.
115
// Custom providers defined entirely in config are also supported.
116
func ResolveProvider(key string, pc ProviderConfig) *provider.Provider {
117
	p, exists := provider.Registry[key]
118
	if !exists {
119
		// Fully custom provider from config.
120
		compat := pc.Compat
121
		if compat == "" {
122
			compat = "openai"
123
		}
124
		name := pc.Name
125
		if name == "" {
126
			name = key
127
		}
128
		model := pc.Model
129
		if model == "" {
130
			model = key
131
		}
132
		small := pc.SmallModel
133
		if small == "" {
134
			small = model
135
		}
136
		return &provider.Provider{
137
			Name:        name,
138
			Description: pc.Description,
139
			BaseURL:     pc.BaseURL,
140
			Model:       model,
141
			SmallModel:  small,
142
			SonnetModel: model,
143
			OpusModel:   model,
144
			HaikuModel:  small,
145
			TimeoutMS:   "600000",
146
			AuthEnvVar:  "ANTHROPIC_AUTH_TOKEN",
147
			Compat:      compat,
148
		}
149
	}
150
151
	// Apply plan-specific base URL if set.
152
	if pc.Plan != "" {
153
		for _, plan := range p.Plans {
154
			if plan.Key == pc.Plan {
155
				p.BaseURL = plan.BaseURL
156
				break
157
			}
158
		}
159
	}
160
161
	// Merge config overrides onto registry defaults.
162
	if pc.BaseURL != "" {
163
		p.BaseURL = pc.BaseURL
164
	}
165
	if pc.Model != "" {
166
		p.Model = pc.Model
167
		p.SonnetModel = pc.Model
168
		p.OpusModel = pc.Model
169
	}
170
	if pc.SmallModel != "" {
171
		p.SmallModel = pc.SmallModel
172
		p.HaikuModel = pc.SmallModel
173
	}
174
	if pc.Compat != "" {
175
		p.Compat = pc.Compat
176
	}
177
	if pc.ContextWindow > 0 {
178
		p.ContextWindow = pc.ContextWindow
179
	}
180
	return &p
181
}
182
183
// Save writes the config to disk.
184
func Save(cfg *Config) error {
185
	p := Path()
186
	if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
187
		return err
188
	}
189
	data, err := yaml.Marshal(cfg)
190
	if err != nil {
191
		return err
192
	}
193
	return os.WriteFile(p, data, 0o600)
194
}
195

Source Files