post inicial
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseValid(t *testing.T) {
|
||||
in := `
|
||||
# Motor de inferencia
|
||||
API_URL=https://servidor.example.com/v1/
|
||||
|
||||
# Puede quedar vacío
|
||||
API_KEY="sk-1234567890abcdef"
|
||||
MODEL = qwen3.5
|
||||
CONTEXT_SIZE=32768
|
||||
FUTURE_OPTION=x
|
||||
`
|
||||
cfg, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if cfg.APIURL != "https://servidor.example.com/v1" {
|
||||
t.Errorf("APIURL = %q (trailing slash must be removed)", cfg.APIURL)
|
||||
}
|
||||
if cfg.APIKey != "sk-1234567890abcdef" {
|
||||
t.Errorf("APIKey = %q", cfg.APIKey)
|
||||
}
|
||||
if cfg.Model != "qwen3.5" {
|
||||
t.Errorf("Model = %q", cfg.Model)
|
||||
}
|
||||
if cfg.ContextSize != 32768 {
|
||||
t.Errorf("ContextSize = %d", cfg.ContextSize)
|
||||
}
|
||||
if cfg.RequestTimeout != DefaultRequestTimeout {
|
||||
t.Errorf("RequestTimeout = %v", cfg.RequestTimeout)
|
||||
}
|
||||
if len(cfg.Unknown) != 1 || cfg.Unknown[0] != "FUTURE_OPTION" {
|
||||
t.Errorf("Unknown = %v", cfg.Unknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDefaults(t *testing.T) {
|
||||
cfg, err := Parse(strings.NewReader("API_URL=http://127.0.0.1:8080/v1\nMODEL=m\nREQUEST_TIMEOUT=10\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if cfg.ContextSize != 0 {
|
||||
t.Errorf("missing CONTEXT_SIZE must stay 0 (server default), got %d", cfg.ContextSize)
|
||||
}
|
||||
if cfg.APIKey != "" {
|
||||
t.Errorf("APIKey should be empty")
|
||||
}
|
||||
if cfg.RequestTimeout != 10*time.Second {
|
||||
t.Errorf("RequestTimeout = %v", cfg.RequestTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseErrors(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"no equals": "API_URL=http://x/v1\nMODEL=m\ngarbage\n",
|
||||
"duplicate": "API_URL=http://x/v1\nMODEL=m\nMODEL=n\n",
|
||||
"missing url": "MODEL=m\n",
|
||||
"missing model": "API_URL=http://x/v1\n",
|
||||
"bad scheme": "API_URL=ftp://x/v1\nMODEL=m\n",
|
||||
"bad ctx": "API_URL=http://x/v1\nMODEL=m\nCONTEXT_SIZE=abc\n",
|
||||
"negative ctx": "API_URL=http://x/v1\nMODEL=m\nCONTEXT_SIZE=-1\n",
|
||||
}
|
||||
for name, in := range cases {
|
||||
if _, err := Parse(strings.NewReader(in)); err == nil {
|
||||
t.Errorf("%s: expected error", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMissingFile(t *testing.T) {
|
||||
_, err := Load(filepath.Join(t.TempDir(), FileName))
|
||||
if err == nil || !os.IsNotExist(unwrapAll(err)) {
|
||||
t.Fatalf("expected not-exist error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskSecret(t *testing.T) {
|
||||
if got := MaskSecret(""); got != "(empty)" {
|
||||
t.Errorf("empty: %q", got)
|
||||
}
|
||||
if got := MaskSecret("short"); got != "***" {
|
||||
t.Errorf("short: %q", got)
|
||||
}
|
||||
got := MaskSecret("sk-2f1abcdefghijk8a9")
|
||||
if got != "sk-2f1...8a9" {
|
||||
t.Errorf("long: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func unwrapAll(err error) error {
|
||||
for {
|
||||
u, ok := err.(interface{ Unwrap() error })
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
err = u.Unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCalling(t *testing.T) {
|
||||
base := "API_URL=http://x/v1\nMODEL=m\n"
|
||||
cfg, err := Parse(strings.NewReader(base))
|
||||
if err != nil || cfg.ToolCalling != "auto" {
|
||||
t.Fatalf("default: %v %v", cfg, err)
|
||||
}
|
||||
cfg, err = Parse(strings.NewReader(base + "TOOL_CALLING=JSON\n"))
|
||||
if err != nil || cfg.ToolCalling != "json" {
|
||||
t.Fatalf("json: %v %v", cfg, err)
|
||||
}
|
||||
if _, err := Parse(strings.NewReader(base + "TOOL_CALLING=magic\n")); err == nil {
|
||||
t.Error("invalid TOOL_CALLING must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchURL(t *testing.T) {
|
||||
base := "API_URL=http://x/v1\nMODEL=m\n"
|
||||
cfg, err := Parse(strings.NewReader(base + "SEARCH_URL=https://searx.local/search?q={query}&format=json\n"))
|
||||
if err != nil || !strings.Contains(cfg.SearchURL, "{query}") {
|
||||
t.Fatalf("%v %v", cfg, err)
|
||||
}
|
||||
for _, bad := range []string{"SEARCH_URL=https://searx.local/search\n", "SEARCH_URL=ftp://x/{query}\n"} {
|
||||
if _, err := Parse(strings.NewReader(base + bad)); err == nil {
|
||||
t.Errorf("%q must fail", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user