Some checks failed
CI / build-and-test (push) Failing after -32m45s
* migrate legacy schemas in memory only * log hint to use amcs-migrate-config for persistence
201 lines
5.1 KiB
Go
201 lines
5.1 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.warky.dev/wdevs/amcs/internal/buildinfo"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
func Load(explicitPath string) (*Config, string, error) {
|
|
return LoadWithLogger(explicitPath, nil)
|
|
}
|
|
|
|
// LoadWithLogger is Load with a logger surface for migration notices. Passing
|
|
// nil is fine — migration events will simply not be logged.
|
|
func LoadWithLogger(explicitPath string, log *slog.Logger) (*Config, string, error) {
|
|
path := ResolvePath(explicitPath)
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, path, fmt.Errorf("read config %q: %w", path, err)
|
|
}
|
|
|
|
raw := map[string]any{}
|
|
if err := yaml.Unmarshal(data, &raw); err != nil {
|
|
return nil, path, fmt.Errorf("decode config %q: %w", path, err)
|
|
}
|
|
if raw == nil {
|
|
raw = map[string]any{}
|
|
}
|
|
|
|
applied, err := Migrate(raw)
|
|
if err != nil {
|
|
return nil, path, fmt.Errorf("migrate config %q: %w", path, err)
|
|
}
|
|
|
|
if len(applied) > 0 {
|
|
if log != nil {
|
|
for _, step := range applied {
|
|
log.Warn("config migrated in memory",
|
|
slog.String("path", path),
|
|
slog.Int("from_version", step.From),
|
|
slog.Int("to_version", step.To),
|
|
slog.String("describe", step.Describe),
|
|
slog.String("hint", "persist with amcs-migrate-config"),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
cfg, err := decodeTyped(raw)
|
|
if err != nil {
|
|
return nil, path, fmt.Errorf("decode migrated config %q: %w", path, err)
|
|
}
|
|
cfg.Version = CurrentConfigVersion
|
|
|
|
applyEnvOverrides(&cfg)
|
|
if err := cfg.Validate(); err != nil {
|
|
return nil, path, err
|
|
}
|
|
|
|
return &cfg, path, nil
|
|
}
|
|
|
|
func decodeTyped(raw map[string]any) (Config, error) {
|
|
out, err := yaml.Marshal(raw)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("re-marshal migrated config: %w", err)
|
|
}
|
|
cfg := defaultConfig()
|
|
if err := yaml.Unmarshal(out, &cfg); err != nil {
|
|
return Config{}, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func ResolvePath(explicitPath string) string {
|
|
if path := strings.TrimSpace(explicitPath); path != "" {
|
|
if path != ".yaml" && path != ".yml" {
|
|
return path
|
|
}
|
|
}
|
|
|
|
if envPath := strings.TrimSpace(os.Getenv("AMCS_CONFIG")); envPath != "" {
|
|
return envPath
|
|
}
|
|
|
|
return DefaultConfigPath
|
|
}
|
|
|
|
func defaultConfig() Config {
|
|
info := buildinfo.Current()
|
|
return Config{
|
|
Version: CurrentConfigVersion,
|
|
Server: ServerConfig{
|
|
Host: "0.0.0.0",
|
|
Port: 8080,
|
|
ReadTimeout: 10 * time.Minute,
|
|
WriteTimeout: 10 * time.Minute,
|
|
IdleTimeout: 60 * time.Second,
|
|
},
|
|
MCP: MCPConfig{
|
|
Path: "/mcp",
|
|
SSEPath: "/sse",
|
|
ServerName: "amcs",
|
|
Version: info.Version,
|
|
Transport: "streamable_http",
|
|
SessionTimeout: 10 * time.Minute,
|
|
},
|
|
Auth: AuthConfig{
|
|
HeaderName: "x-brain-key",
|
|
QueryParam: "key",
|
|
},
|
|
AI: AIConfig{
|
|
Providers: map[string]ProviderConfig{},
|
|
Embeddings: EmbeddingsRoleConfig{
|
|
Dimensions: 1536,
|
|
},
|
|
Metadata: MetadataRoleConfig{
|
|
Temperature: 0.1,
|
|
Timeout: 10 * time.Second,
|
|
},
|
|
},
|
|
Capture: CaptureConfig{
|
|
Source: DefaultSource,
|
|
MetadataDefaults: CaptureMetadataDefault{
|
|
Type: "observation",
|
|
TopicFallback: "uncategorized",
|
|
},
|
|
},
|
|
Search: SearchConfig{
|
|
DefaultLimit: 10,
|
|
DefaultThreshold: 0.5,
|
|
MaxLimit: 50,
|
|
},
|
|
Logging: LoggingConfig{
|
|
Level: "info",
|
|
Format: "json",
|
|
},
|
|
Backfill: BackfillConfig{
|
|
Enabled: false,
|
|
RunOnStartup: false,
|
|
Interval: 15 * time.Minute,
|
|
BatchSize: 20,
|
|
MaxPerRun: 100,
|
|
},
|
|
MetadataRetry: MetadataRetryConfig{
|
|
Enabled: false,
|
|
RunOnStartup: false,
|
|
Interval: 24 * time.Hour,
|
|
MaxPerRun: 100,
|
|
},
|
|
}
|
|
}
|
|
|
|
func applyEnvOverrides(cfg *Config) {
|
|
overrideString(&cfg.Database.URL, "AMCS_DATABASE_URL")
|
|
overrideString(&cfg.MCP.PublicURL, "AMCS_PUBLIC_URL")
|
|
|
|
overrideProviderField(cfg, "AMCS_LITELLM_BASE_URL", "litellm", func(p *ProviderConfig, v string) { p.BaseURL = v })
|
|
overrideProviderField(cfg, "AMCS_LITELLM_API_KEY", "litellm", func(p *ProviderConfig, v string) { p.APIKey = v })
|
|
overrideProviderField(cfg, "AMCS_OLLAMA_BASE_URL", "ollama", func(p *ProviderConfig, v string) { p.BaseURL = v })
|
|
overrideProviderField(cfg, "AMCS_OLLAMA_API_KEY", "ollama", func(p *ProviderConfig, v string) { p.APIKey = v })
|
|
overrideProviderField(cfg, "AMCS_OPENROUTER_API_KEY", "openrouter", func(p *ProviderConfig, v string) { p.APIKey = v })
|
|
|
|
if value, ok := os.LookupEnv("AMCS_SERVER_PORT"); ok {
|
|
if port, err := strconv.Atoi(strings.TrimSpace(value)); err == nil {
|
|
cfg.Server.Port = port
|
|
}
|
|
}
|
|
}
|
|
|
|
// overrideProviderField applies an env var to every configured provider of the
|
|
// given type. This preserves the v1 behaviour where e.g. AMCS_LITELLM_API_KEY
|
|
// rewrote the single litellm block — in v2 it rewrites every litellm provider.
|
|
func overrideProviderField(cfg *Config, envKey, providerType string, apply func(*ProviderConfig, string)) {
|
|
value, ok := os.LookupEnv(envKey)
|
|
if !ok {
|
|
return
|
|
}
|
|
value = strings.TrimSpace(value)
|
|
for name, p := range cfg.AI.Providers {
|
|
if p.Type != providerType {
|
|
continue
|
|
}
|
|
apply(&p, value)
|
|
cfg.AI.Providers[name] = p
|
|
}
|
|
}
|
|
|
|
func overrideString(target *string, envKey string) {
|
|
if value, ok := os.LookupEnv(envKey); ok {
|
|
*target = strings.TrimSpace(value)
|
|
}
|
|
}
|