post inicial

This commit is contained in:
lastdragon
2026-09-27 16:04:02 -06:00
parent d84587e1f3
commit 52864794f5
67 changed files with 15023 additions and 2 deletions
+208
View File
@@ -0,0 +1,208 @@
// 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
}