| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242 |
- // Package config loads workflow tool configuration from environment variables
- // and/or a KEY=VALUE config file (`.env` format).
- //
- // # Load order (highest priority first)
- //
- // 1. Environment variables (always override everything else)
- // 2. Config file — path resolution:
- // a. Explicit path passed to Load()
- // b. WORKFLOW_CONFIG environment variable
- // c. .env in the current working directory
- // d. .workflow.env in the current working directory
- //
- // # Config file format
- //
- // Plain KEY=VALUE pairs, one per line. Lines starting with # are comments.
- //
- // # .env
- // LLM_URL=http://localhost:4000
- // LLM_KEY=sk-xxx
- // LLM_MODEL=gpt-4o
- // WORKSPACE_ROOT=./workspace
- // PORT=8080
- //
- // # v3.16+: Multi-provider configuration
- // OPENAI_API_KEY=sk-openai-xxx
- // OPENAI_MODEL=gpt-4.1
- // ANTHROPIC_API_KEY=sk-ant-xxx
- // ANTHROPIC_MODEL=claude-sonnet-4-5-20250929
- //
- // # For new users
- //
- // Copy .env.example to .env and fill in your credentials.
- // Never commit .env to version control.
- package config
- import (
- "bufio"
- "os"
- "strings"
- )
- // Config holds all runtime configuration for the workflow tools.
- type Config struct {
- LLM LLMConfig
- Providers map[string]ProviderConfig // v3.16+: per-provider settings keyed by provider name ("openai", "anthropic")
- Workspace WorkspaceConfig
- Server ServerConfig
- }
- // LLMConfig holds global/default LLM endpoint settings (backward-compatible with pre-3.16).
- type LLMConfig struct {
- URL string // LLM_URL (default: http://localhost:4000)
- Key string // LLM_KEY (fallback key if provider-specific key not set)
- Model string // LLM_MODEL (default: gpt-4o; v3.16+: supports "provider/modelId" format)
- }
- // ProviderConfig holds per-provider LLM settings (v3.16+).
- type ProviderConfig struct {
- APIKey string // e.g. OPENAI_API_KEY, ANTHROPIC_API_KEY
- Model string // e.g. OPENAI_MODEL, ANTHROPIC_MODEL (default modelId for this provider)
- BaseURL string // e.g. OPENAI_BASE_URL (optional URL override)
- }
- // WorkspaceConfig holds local file workspace settings.
- type WorkspaceConfig struct {
- Root string // WORKSPACE_ROOT (default: ./workspace)
- }
- // ServerConfig holds HTTP server settings.
- type ServerConfig struct {
- Port string // PORT (default: 8080)
- }
- // defaults returns a Config pre-populated with all default values.
- func defaults() Config {
- return Config{
- LLM: LLMConfig{
- URL: "http://localhost:4000",
- Model: "gpt-4o",
- },
- Providers: make(map[string]ProviderConfig),
- Workspace: WorkspaceConfig{
- Root: "./workspace",
- },
- Server: ServerConfig{
- Port: "8080",
- },
- }
- }
- // Load reads configuration using the priority chain described in the package doc.
- // Pass an empty string for configFile to use automatic file discovery.
- func Load(configFile string) Config {
- cfg := defaults()
- // Resolve config file path
- path := resolveFilePath(configFile)
- // 1. Apply file values (lowest priority)
- if path != "" {
- applyFile(&cfg, path)
- }
- // 2. Apply environment variables (highest priority — always win)
- applyEnv(&cfg)
- return cfg
- }
- // resolveFilePath returns the config file path to use, or "" if none found.
- func resolveFilePath(explicit string) string {
- // a. Explicit argument
- if explicit != "" {
- return explicit
- }
- // b. WORKFLOW_CONFIG env var
- if v := os.Getenv("WORKFLOW_CONFIG"); v != "" {
- return v
- }
- // c. .env in CWD
- if _, err := os.Stat(".env"); err == nil {
- return ".env"
- }
- // d. .workflow.env in CWD
- if _, err := os.Stat(".workflow.env"); err == nil {
- return ".workflow.env"
- }
- return ""
- }
- // applyFile reads KEY=VALUE pairs from path and applies them to cfg.
- // Missing file or unrecognised keys are silently ignored.
- func applyFile(cfg *Config, path string) {
- f, err := os.Open(path)
- if err != nil {
- return
- }
- defer f.Close()
- kv := parseKV(bufio.NewScanner(f))
- applyKV(cfg, kv)
- }
- // applyEnv reads relevant environment variables and applies them to cfg.
- func applyEnv(cfg *Config) {
- kv := make(map[string]string)
- for _, key := range []string{
- "LLM_URL", "LLM_KEY", "LLM_MODEL",
- "OPENAI_API_KEY", "OPENAI_MODEL", "OPENAI_BASE_URL",
- "ANTHROPIC_API_KEY", "ANTHROPIC_MODEL", "ANTHROPIC_BASE_URL",
- "WORKSPACE_ROOT",
- "PORT",
- } {
- if v := os.Getenv(key); v != "" {
- kv[key] = v
- }
- }
- applyKV(cfg, kv)
- }
- // applyKV maps a flat key→value map onto cfg fields.
- func applyKV(cfg *Config, kv map[string]string) {
- // Global LLM settings (backward-compatible)
- if v, ok := kv["LLM_URL"]; ok && v != "" {
- cfg.LLM.URL = v
- }
- if v, ok := kv["LLM_KEY"]; ok {
- cfg.LLM.Key = v // allow empty string (clears previous value)
- }
- if v, ok := kv["LLM_MODEL"]; ok && v != "" {
- cfg.LLM.Model = v
- }
- // v3.16+: Per-provider settings
- if cfg.Providers == nil {
- cfg.Providers = make(map[string]ProviderConfig)
- }
- applyProviderKV(cfg, "openai", kv, "OPENAI_API_KEY", "OPENAI_MODEL", "OPENAI_BASE_URL")
- applyProviderKV(cfg, "anthropic", kv, "ANTHROPIC_API_KEY", "ANTHROPIC_MODEL", "ANTHROPIC_BASE_URL")
- // Workspace and server settings
- if v, ok := kv["WORKSPACE_ROOT"]; ok && v != "" {
- cfg.Workspace.Root = v
- }
- if v, ok := kv["PORT"]; ok && v != "" {
- cfg.Server.Port = v
- }
- }
- // applyProviderKV applies provider-level config from the kv map.
- func applyProviderKV(cfg *Config, name string, kv map[string]string, keyEnv, modelEnv, urlEnv string) {
- p := cfg.Providers[name]
- if v, ok := kv[keyEnv]; ok && v != "" {
- p.APIKey = v
- }
- if v, ok := kv[modelEnv]; ok && v != "" {
- p.Model = v
- }
- if v, ok := kv[urlEnv]; ok && v != "" {
- p.BaseURL = v
- }
- if p.APIKey != "" || p.Model != "" || p.BaseURL != "" {
- cfg.Providers[name] = p
- }
- }
- // parseKV scans lines from s, returning a KEY→value map.
- // Lines that are blank or start with # are ignored.
- // Lines without '=' are ignored.
- func parseKV(s *bufio.Scanner) map[string]string {
- m := make(map[string]string)
- for s.Scan() {
- line := strings.TrimSpace(s.Text())
- if line == "" || strings.HasPrefix(line, "#") {
- continue
- }
- k, v, ok := strings.Cut(line, "=")
- if !ok {
- continue
- }
- k = strings.TrimSpace(k)
- v = strings.TrimSpace(v)
- // Strip optional surrounding quotes (" or ')
- if len(v) >= 2 && ((v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'')) {
- v = v[1 : len(v)-1]
- }
- m[k] = v
- }
- return m
- }
- // MaskKey returns a masked representation of an API key for logging.
- func MaskKey(key string) string {
- switch {
- case key == "":
- return "(not set)"
- case len(key) <= 8:
- return strings.Repeat("*", len(key))
- default:
- return key[:4] + strings.Repeat("*", len(key)-8) + key[len(key)-4:]
- }
- }
|