config.go 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. // Package config loads workflow tool configuration from environment variables
  2. // and/or a KEY=VALUE config file (`.env` format).
  3. //
  4. // # Load order (highest priority first)
  5. //
  6. // 1. Environment variables (always override everything else)
  7. // 2. Config file — path resolution:
  8. // a. Explicit path passed to Load()
  9. // b. WORKFLOW_CONFIG environment variable
  10. // c. .env in the current working directory
  11. // d. .workflow.env in the current working directory
  12. //
  13. // # Config file format
  14. //
  15. // Plain KEY=VALUE pairs, one per line. Lines starting with # are comments.
  16. //
  17. // # .env
  18. // LLM_URL=http://localhost:4000
  19. // LLM_KEY=sk-xxx
  20. // LLM_MODEL=gpt-4o
  21. // WORKSPACE_ROOT=./workspace
  22. // PORT=8080
  23. //
  24. // # v3.16+: Multi-provider configuration
  25. // OPENAI_API_KEY=sk-openai-xxx
  26. // OPENAI_MODEL=gpt-4.1
  27. // ANTHROPIC_API_KEY=sk-ant-xxx
  28. // ANTHROPIC_MODEL=claude-sonnet-4-5-20250929
  29. //
  30. // # For new users
  31. //
  32. // Copy .env.example to .env and fill in your credentials.
  33. // Never commit .env to version control.
  34. package config
  35. import (
  36. "bufio"
  37. "os"
  38. "strings"
  39. )
  40. // Config holds all runtime configuration for the workflow tools.
  41. type Config struct {
  42. LLM LLMConfig
  43. Providers map[string]ProviderConfig // v3.16+: per-provider settings keyed by provider name ("openai", "anthropic")
  44. Workspace WorkspaceConfig
  45. Server ServerConfig
  46. }
  47. // LLMConfig holds global/default LLM endpoint settings (backward-compatible with pre-3.16).
  48. type LLMConfig struct {
  49. URL string // LLM_URL (default: http://localhost:4000)
  50. Key string // LLM_KEY (fallback key if provider-specific key not set)
  51. Model string // LLM_MODEL (default: gpt-4o; v3.16+: supports "provider/modelId" format)
  52. }
  53. // ProviderConfig holds per-provider LLM settings (v3.16+).
  54. type ProviderConfig struct {
  55. APIKey string // e.g. OPENAI_API_KEY, ANTHROPIC_API_KEY
  56. Model string // e.g. OPENAI_MODEL, ANTHROPIC_MODEL (default modelId for this provider)
  57. BaseURL string // e.g. OPENAI_BASE_URL (optional URL override)
  58. }
  59. // WorkspaceConfig holds local file workspace settings.
  60. type WorkspaceConfig struct {
  61. Root string // WORKSPACE_ROOT (default: ./workspace)
  62. }
  63. // ServerConfig holds HTTP server settings.
  64. type ServerConfig struct {
  65. Port string // PORT (default: 8080)
  66. }
  67. // defaults returns a Config pre-populated with all default values.
  68. func defaults() Config {
  69. return Config{
  70. LLM: LLMConfig{
  71. URL: "http://localhost:4000",
  72. Model: "gpt-4o",
  73. },
  74. Providers: make(map[string]ProviderConfig),
  75. Workspace: WorkspaceConfig{
  76. Root: "./workspace",
  77. },
  78. Server: ServerConfig{
  79. Port: "8080",
  80. },
  81. }
  82. }
  83. // Load reads configuration using the priority chain described in the package doc.
  84. // Pass an empty string for configFile to use automatic file discovery.
  85. func Load(configFile string) Config {
  86. cfg := defaults()
  87. // Resolve config file path
  88. path := resolveFilePath(configFile)
  89. // 1. Apply file values (lowest priority)
  90. if path != "" {
  91. applyFile(&cfg, path)
  92. }
  93. // 2. Apply environment variables (highest priority — always win)
  94. applyEnv(&cfg)
  95. return cfg
  96. }
  97. // resolveFilePath returns the config file path to use, or "" if none found.
  98. func resolveFilePath(explicit string) string {
  99. // a. Explicit argument
  100. if explicit != "" {
  101. return explicit
  102. }
  103. // b. WORKFLOW_CONFIG env var
  104. if v := os.Getenv("WORKFLOW_CONFIG"); v != "" {
  105. return v
  106. }
  107. // c. .env in CWD
  108. if _, err := os.Stat(".env"); err == nil {
  109. return ".env"
  110. }
  111. // d. .workflow.env in CWD
  112. if _, err := os.Stat(".workflow.env"); err == nil {
  113. return ".workflow.env"
  114. }
  115. return ""
  116. }
  117. // applyFile reads KEY=VALUE pairs from path and applies them to cfg.
  118. // Missing file or unrecognised keys are silently ignored.
  119. func applyFile(cfg *Config, path string) {
  120. f, err := os.Open(path)
  121. if err != nil {
  122. return
  123. }
  124. defer f.Close()
  125. kv := parseKV(bufio.NewScanner(f))
  126. applyKV(cfg, kv)
  127. }
  128. // applyEnv reads relevant environment variables and applies them to cfg.
  129. func applyEnv(cfg *Config) {
  130. kv := make(map[string]string)
  131. for _, key := range []string{
  132. "LLM_URL", "LLM_KEY", "LLM_MODEL",
  133. "OPENAI_API_KEY", "OPENAI_MODEL", "OPENAI_BASE_URL",
  134. "ANTHROPIC_API_KEY", "ANTHROPIC_MODEL", "ANTHROPIC_BASE_URL",
  135. "WORKSPACE_ROOT",
  136. "PORT",
  137. } {
  138. if v := os.Getenv(key); v != "" {
  139. kv[key] = v
  140. }
  141. }
  142. applyKV(cfg, kv)
  143. }
  144. // applyKV maps a flat key→value map onto cfg fields.
  145. func applyKV(cfg *Config, kv map[string]string) {
  146. // Global LLM settings (backward-compatible)
  147. if v, ok := kv["LLM_URL"]; ok && v != "" {
  148. cfg.LLM.URL = v
  149. }
  150. if v, ok := kv["LLM_KEY"]; ok {
  151. cfg.LLM.Key = v // allow empty string (clears previous value)
  152. }
  153. if v, ok := kv["LLM_MODEL"]; ok && v != "" {
  154. cfg.LLM.Model = v
  155. }
  156. // v3.16+: Per-provider settings
  157. if cfg.Providers == nil {
  158. cfg.Providers = make(map[string]ProviderConfig)
  159. }
  160. applyProviderKV(cfg, "openai", kv, "OPENAI_API_KEY", "OPENAI_MODEL", "OPENAI_BASE_URL")
  161. applyProviderKV(cfg, "anthropic", kv, "ANTHROPIC_API_KEY", "ANTHROPIC_MODEL", "ANTHROPIC_BASE_URL")
  162. // Workspace and server settings
  163. if v, ok := kv["WORKSPACE_ROOT"]; ok && v != "" {
  164. cfg.Workspace.Root = v
  165. }
  166. if v, ok := kv["PORT"]; ok && v != "" {
  167. cfg.Server.Port = v
  168. }
  169. }
  170. // applyProviderKV applies provider-level config from the kv map.
  171. func applyProviderKV(cfg *Config, name string, kv map[string]string, keyEnv, modelEnv, urlEnv string) {
  172. p := cfg.Providers[name]
  173. if v, ok := kv[keyEnv]; ok && v != "" {
  174. p.APIKey = v
  175. }
  176. if v, ok := kv[modelEnv]; ok && v != "" {
  177. p.Model = v
  178. }
  179. if v, ok := kv[urlEnv]; ok && v != "" {
  180. p.BaseURL = v
  181. }
  182. if p.APIKey != "" || p.Model != "" || p.BaseURL != "" {
  183. cfg.Providers[name] = p
  184. }
  185. }
  186. // parseKV scans lines from s, returning a KEY→value map.
  187. // Lines that are blank or start with # are ignored.
  188. // Lines without '=' are ignored.
  189. func parseKV(s *bufio.Scanner) map[string]string {
  190. m := make(map[string]string)
  191. for s.Scan() {
  192. line := strings.TrimSpace(s.Text())
  193. if line == "" || strings.HasPrefix(line, "#") {
  194. continue
  195. }
  196. k, v, ok := strings.Cut(line, "=")
  197. if !ok {
  198. continue
  199. }
  200. k = strings.TrimSpace(k)
  201. v = strings.TrimSpace(v)
  202. // Strip optional surrounding quotes (" or ')
  203. if len(v) >= 2 && ((v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'')) {
  204. v = v[1 : len(v)-1]
  205. }
  206. m[k] = v
  207. }
  208. return m
  209. }
  210. // MaskKey returns a masked representation of an API key for logging.
  211. func MaskKey(key string) string {
  212. switch {
  213. case key == "":
  214. return "(not set)"
  215. case len(key) <= 8:
  216. return strings.Repeat("*", len(key))
  217. default:
  218. return key[:4] + strings.Repeat("*", len(key)-8) + key[len(key)-4:]
  219. }
  220. }