Files
soft_usite/rest/controllers/api/ia_controller.go
T
Lizandro GuarnizoandClaude Sonnet 5 96cd24f17d feat(ai-config): cifra la API key en reposo y la acota por tenant
Prepara el terreno para que el cliente cargue su propia cuenta de IA sin
que su clave quede en texto plano ni que el selector le muestre las de
los demás.

- ClaveEnClaro() descifra con fallback a texto plano: las filas viejas se
  leen igual y quedan cifradas al primer guardado, sin script ni downtime.
  utils.Decrypt hace panic (no devuelve error) con entrada que no es un
  ciphertext válido, así que el fallback va sobre recover — eso mismo es
  el mecanismo de detección de "todavía está en claro".
- Migrados TODOS los lectores: uMind (chat y embeddings), bot de Telegram,
  Whisper, Query Runner, streaming de IA y Landing Generator. Un lector
  sin migrar mandaría el ciphertext como API key.
- AiConfig gana TenantID (null = global del staff) y
  GetAiConfigSelectPorTenants para acotar el selector.
- Test de los 4 casos del fallback, incluido hex válido que no descifra.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 11:36:47 -05:00

243 lines
5.8 KiB
Go

package controllers
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
type DataIa struct {
Prompt string `json:"prompt"`
Model string `json:"model"`
Stream bool `json:"stream"`
}
type respuestaOllama struct {
Response string `json:"response"`
Done bool `json:"done"`
Error string `json:"error"`
}
type openaiChatMsg struct {
Role string `json:"role"`
Content string `json:"content"`
}
type openaiChatReq struct {
Model string `json:"model"`
Messages []openaiChatMsg `json:"messages"`
Stream bool `json:"stream"`
}
type openaiChatChoice struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
}
type openaiChatResp struct {
Choices []openaiChatChoice `json:"choices"`
}
type geminiPart struct {
Text string `json:"text"`
}
type geminiContent struct {
Parts []geminiPart `json:"parts"`
}
type geminiReq struct {
Contents []geminiContent `json:"contents"`
}
type geminiCandidate struct {
Content geminiContent `json:"content"`
FinishReason *string `json:"finishReason"`
}
type geminiResp struct {
Candidates []geminiCandidate `json:"candidates"`
}
func GeneraTextoStream(c *fiber.Ctx) error {
var data DataIa
if err := c.BodyParser(&data); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Datos inválidos"})
}
data.Stream = true
config, err := models.GetAiConfigForService("ia")
if err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
"error": "No hay configuración de IA activa. Configura una en /app/ai-config",
})
}
if data.Model == "" {
data.Model = config.ModelName
}
if data.Model == "" {
data.Model = "gemma3:1b"
}
provider := strings.ToLower(config.Provider)
baseURL := strings.TrimRight(config.BaseURL, "/")
hasV1 := strings.Contains(baseURL, "/v1")
var endpoint string
var bodyReader io.Reader
useOpenAI := provider != "ollama" || hasV1
useGemini := provider == "gemini"
if useGemini {
model := data.Model
endpoint = fmt.Sprintf("https://generativelanguage.googleapis.com/v1beta/models/%s:streamGenerateContent?alt=sse&key=%s", model, config.ClaveEnClaro())
gReq := geminiReq{
Contents: []geminiContent{
{Parts: []geminiPart{{Text: data.Prompt}}},
},
}
jsonData, _ := json.Marshal(gReq)
bodyReader = bytes.NewBuffer(jsonData)
} else if !useOpenAI {
// Native Ollama API
endpoint = baseURL + "/api/generate"
jsonData, _ := json.Marshal(data)
bodyReader = bytes.NewBuffer(jsonData)
} else {
// OpenAI-compatible (/v1/chat/completions)
baseURL = strings.TrimSuffix(baseURL, "/v1")
endpoint = baseURL + "/v1/chat/completions"
chatReq := openaiChatReq{
Model: data.Model,
Messages: []openaiChatMsg{
{Role: "user", Content: data.Prompt},
},
Stream: true,
}
jsonData, _ := json.Marshal(chatReq)
bodyReader = bytes.NewBuffer(jsonData)
}
log.Printf("[IA] endpoint=%s model=%s provider=%s", endpoint, data.Model, provider)
req, err := http.NewRequest("POST", endpoint, bodyReader)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
// Auth
clave := config.ClaveEnClaro()
if useGemini {
// API key ya va en la URL
} else if provider == "ollama" && clave != "" && clave != "ollama" {
req.SetBasicAuth("ollama", clave)
} else if clave != "" {
req.Header.Set("Authorization", "Bearer "+clave)
}
client := &http.Client{Timeout: 120 * time.Second}
resp, err := client.Do(req)
if err != nil {
log.Printf("[IA] Error conectando: %v", err)
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{
"error": fmt.Sprintf("Error conectando a %s: %v", provider, err),
})
}
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
resp.Body.Close()
log.Printf("[IA] %s status %d: %s", provider, resp.StatusCode, string(bodyBytes))
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{
"error": fmt.Sprintf("%s respondió con status %d", provider, resp.StatusCode),
"details": string(bodyBytes),
})
}
c.Set("Content-Type", "text/plain; charset=utf-8")
c.Set("Cache-Control", "no-cache")
c.Set("X-Accel-Buffering", "no")
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
if useGemini {
if !strings.HasPrefix(line, "data: ") {
continue
}
var msg geminiResp
if err := json.Unmarshal([]byte(line[6:]), &msg); err != nil {
continue
}
if len(msg.Candidates) > 0 && len(msg.Candidates[0].Content.Parts) > 0 {
text := msg.Candidates[0].Content.Parts[0].Text
if text != "" {
fmt.Fprint(w, text)
w.Flush()
}
if msg.Candidates[0].FinishReason != nil {
break
}
}
} else if useOpenAI {
var msg openaiChatResp
if err := json.Unmarshal([]byte(line), &msg); err != nil {
if err != io.EOF {
log.Printf("[IA] Error decodificando respuesta OpenAI: %v", err)
}
continue
}
if len(msg.Choices) == 0 {
continue
}
content := msg.Choices[0].Delta.Content
if content != "" {
fmt.Fprint(w, content)
w.Flush()
}
if msg.Choices[0].FinishReason != nil {
break
}
} else {
var msg respuestaOllama
if err := json.Unmarshal([]byte(line), &msg); err != nil {
continue
}
if msg.Error != "" {
log.Printf("[IA] Ollama error: %s", msg.Error)
fmt.Fprintf(w, "[Error: %s]", msg.Error)
w.Flush()
break
}
if msg.Response != "" {
fmt.Fprint(w, msg.Response)
w.Flush()
}
if msg.Done {
break
}
}
}
})
return nil
}