// Package config carga y valida init.conf, el archivo que describe el motor // de inferencia (URL de la API, clave, modelo y tamaño de contexto). // // El formato es deliberadamente simple: una directiva KEY=VALUE por línea, // con soporte para comentarios (#) y líneas vacías. No se usa una librería // INI externa porque el formato no lo justifica y así se evita una dependencia. package config import ( "bufio" "fmt" "io" "net/url" "os" "strconv" "strings" "time" ) // FileName es el nombre del archivo de configuración dentro de PROJECT_ROOT. const FileName = "init.conf" // DefaultRequestTimeout limita cuánto puede tardar una petición al modelo. // Los modelos locales pueden ser lentos, por eso el valor es generoso. const DefaultRequestTimeout = 300 * time.Second // Config contiene la configuración del motor de inferencia. // // ContextSize == 0 significa "desconocido: usar el valor del servidor". // Nunca se inventa un tamaño cuando no está configurado. type Config struct { APIURL string APIKey string Model string ContextSize int RequestTimeout time.Duration // ToolCalling: "auto" (nativo con respaldo a JSON), "native" o "json". ToolCalling string // SearchURL es la plantilla del buscador web; {query} se sustituye por // la consulta codificada. Vacío = DuckDuckGo HTML. SearchURL string // Unknown guarda las claves no reconocidas para reportarlas en el log // sin abortar el arranque (compatibilidad con versiones futuras). Unknown []string } // Load lee y valida init.conf desde la ruta indicada. func Load(path string) (*Config, error) { f, err := os.Open(path) if err != nil { return nil, fmt.Errorf("failed to open %s: %w", path, err) } defer f.Close() cfg, err := Parse(f) if err != nil { return nil, fmt.Errorf("failed to parse %s: %w", path, err) } return cfg, nil } // Parse interpreta el contenido de init.conf. // // Reglas: // - Las líneas vacías y las que empiezan con '#' se ignoran. // - Cada línea válida tiene la forma KEY=VALUE; los espacios alrededor de // KEY y VALUE se eliminan y se aceptan comillas envolventes en VALUE. // - Una clave repetida es un error: una configuración ambigua no debe // resolverse en silencio. // - API_URL y MODEL son obligatorias. func Parse(r io.Reader) (*Config, error) { cfg := &Config{RequestTimeout: DefaultRequestTimeout, ToolCalling: "auto"} seen := map[string]int{} sc := bufio.NewScanner(r) lineNo := 0 for sc.Scan() { lineNo++ line := strings.TrimSpace(sc.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } key, value, ok := strings.Cut(line, "=") if !ok { return nil, fmt.Errorf("line %d: expected KEY=VALUE", lineNo) } key = strings.ToUpper(strings.TrimSpace(key)) value = unquote(strings.TrimSpace(value)) if key == "" { return nil, fmt.Errorf("line %d: empty key", lineNo) } if prev, dup := seen[key]; dup { return nil, fmt.Errorf("line %d: duplicate key %s (first defined on line %d)", lineNo, key, prev) } seen[key] = lineNo switch key { case "API_URL": cfg.APIURL = strings.TrimRight(value, "/") case "API_KEY": cfg.APIKey = value case "MODEL": cfg.Model = value case "CONTEXT_SIZE": n, err := parseNonNegativeInt(value) if err != nil { return nil, fmt.Errorf("line %d: CONTEXT_SIZE: %w", lineNo, err) } cfg.ContextSize = n case "REQUEST_TIMEOUT": n, err := parseNonNegativeInt(value) if err != nil { return nil, fmt.Errorf("line %d: REQUEST_TIMEOUT: %w", lineNo, err) } if n > 0 { cfg.RequestTimeout = time.Duration(n) * time.Second } case "SEARCH_URL": if value != "" && !strings.Contains(value, "{query}") { return nil, fmt.Errorf("line %d: SEARCH_URL must contain {query}", lineNo) } if value != "" && !strings.HasPrefix(value, "http://") && !strings.HasPrefix(value, "https://") { return nil, fmt.Errorf("line %d: SEARCH_URL must be an http(s) URL", lineNo) } cfg.SearchURL = value case "TOOL_CALLING": switch v := strings.ToLower(value); v { case "", "auto": cfg.ToolCalling = "auto" case "native", "json": cfg.ToolCalling = v default: return nil, fmt.Errorf("line %d: TOOL_CALLING must be auto, native or json, got %q", lineNo, value) } default: cfg.Unknown = append(cfg.Unknown, key) } } if err := sc.Err(); err != nil { return nil, fmt.Errorf("read error: %w", err) } if err := cfg.validate(); err != nil { return nil, err } return cfg, nil } // validate comprueba que los campos obligatorios tengan valores utilizables. func (c *Config) validate() error { if c.APIURL == "" { return fmt.Errorf("API_URL is required") } u, err := url.Parse(c.APIURL) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { return fmt.Errorf("API_URL must be an http(s) URL, got %q", c.APIURL) } if c.Model == "" { return fmt.Errorf("MODEL is required") } return nil } // MaskedKey devuelve la API key en forma segura para logs y pantalla. func (c *Config) MaskedKey() string { return MaskSecret(c.APIKey) } // MaskSecret oculta la parte central de un secreto. // // Los secretos cortos se ocultan por completo porque mostrar prefijo y sufijo // revelaría una fracción demasiado grande del valor. func MaskSecret(s string) string { switch { case s == "": return "(empty)" case len(s) < 12: return "***" default: return s[:6] + "..." + s[len(s)-3:] } } func parseNonNegativeInt(s string) (int, error) { if s == "" { return 0, nil } n, err := strconv.Atoi(s) if err != nil { return 0, fmt.Errorf("invalid integer %q", s) } if n < 0 { return 0, fmt.Errorf("must be >= 0, got %d", n) } return n, nil } // unquote elimina un par de comillas envolventes, simples o dobles. func unquote(s string) string { if len(s) >= 2 { if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { return s[1 : len(s)-1] } } return s }