Files
iasysadmin/internal/llm/client.go
T
2026-09-27 16:04:02 -06:00

368 lines
11 KiB
Go

package llm
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"regexp"
"strings"
"time"
)
// maxResponseBytes limita el tamaño de una respuesta HTTP del modelo para
// que un servidor defectuoso no pueda agotar la memoria del agente.
const maxResponseBytes = 64 << 20
// OpenAIClient implementa Client sobre la API estilo OpenAI.
//
// Separa el transporte HTTP de la lógica del agente: este tipo solo
// serializa peticiones, gestiona reintentos y decodifica respuestas.
type OpenAIClient struct {
baseURL string
apiKey string
model string
userAgent string
http *http.Client
maxRetries int
backoff time.Duration
}
// Options configura un OpenAIClient.
type Options struct {
BaseURL string
APIKey string // se añade solo en la cabecera Authorization, nunca se registra
Model string
UserAgent string
Timeout time.Duration
}
// NewOpenAIClient crea un cliente con un único http.Client reutilizable
// (pool de conexiones incluido en http.Transport).
func NewOpenAIClient(o Options) *OpenAIClient {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.MaxIdleConnsPerHost = 4
return &OpenAIClient{
baseURL: strings.TrimRight(o.BaseURL, "/"),
apiKey: o.APIKey,
model: o.Model,
userAgent: o.UserAgent,
http: &http.Client{Timeout: o.Timeout, Transport: transport},
maxRetries: 3,
backoff: time.Second,
}
}
// Formato de cable de la API OpenAI.
type wireRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Tools []Tool `json:"tools,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
Stream bool `json:"stream"`
}
type wireResponse struct {
Choices []struct {
Message struct {
Role string `json:"role"`
Content *string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
ToolCalls []ToolCall `json:"tool_calls"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage *struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
// thinkRe elimina bloques <think>...</think> que algunos modelos (Qwen3,
// DeepSeek-R1) incluyen en el contenido cuando el servidor no los separa.
var thinkRe = regexp.MustCompile(`(?s)^\s*<think>(.*?)</think>\s*`)
// Chat envía la conversación al modelo y devuelve su respuesta.
//
// Reintenta errores transitorios (red, 429, 5xx) con backoff exponencial y
// un número máximo de intentos; nunca reintenta indefinidamente.
func (c *OpenAIClient) Chat(ctx context.Context, req ChatRequest) (ChatResponse, error) {
body, err := json.Marshal(wireRequest{
Model: c.model,
Messages: req.Messages,
Tools: req.Tools,
MaxTokens: req.MaxTokens,
Temperature: req.Temperature,
})
if err != nil {
return ChatResponse{}, fmt.Errorf("failed to encode chat request: %w", err)
}
var lastErr error
for attempt := 0; attempt <= c.maxRetries; attempt++ {
if attempt > 0 {
wait := c.backoff << (attempt - 1)
select {
case <-ctx.Done():
return ChatResponse{}, ctx.Err()
case <-time.After(wait):
}
}
resp, retry, err := c.doChat(ctx, body)
if err == nil {
return resp, nil
}
lastErr = err
if !retry || ctx.Err() != nil {
break
}
}
return ChatResponse{}, lastErr
}
// doChat realiza un único intento. retry indica si el error es transitorio.
func (c *OpenAIClient) doChat(ctx context.Context, body []byte) (ChatResponse, bool, error) {
url := c.baseURL + "/chat/completions"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return ChatResponse{}, false, fmt.Errorf("failed to build request for %s: %w", url, err)
}
c.setHeaders(httpReq)
httpReq.Header.Set("Content-Type", "application/json")
httpResp, err := c.http.Do(httpReq)
if err != nil {
return ChatResponse{}, isTransient(err), fmt.Errorf("POST %s: %w", url, err)
}
defer httpResp.Body.Close()
data, err := io.ReadAll(io.LimitReader(httpResp.Body, maxResponseBytes))
if err != nil {
return ChatResponse{}, true, fmt.Errorf("failed to read response from %s: %w", url, err)
}
if httpResp.StatusCode != http.StatusOK {
retry := httpResp.StatusCode == http.StatusTooManyRequests || httpResp.StatusCode >= 500
return ChatResponse{}, retry, &HTTPError{URL: url, Status: httpResp.StatusCode, Body: snippet(data)}
}
var w wireResponse
if err := json.Unmarshal(data, &w); err != nil {
return ChatResponse{}, false, fmt.Errorf("invalid JSON from %s: %w (body: %s)", url, err, snippet(data))
}
if w.Error != nil && w.Error.Message != "" {
return ChatResponse{}, false, fmt.Errorf("model server error: %s", w.Error.Message)
}
if len(w.Choices) == 0 {
return ChatResponse{}, false, fmt.Errorf("model server returned no choices (body: %s)", snippet(data))
}
ch := w.Choices[0]
out := ChatResponse{
FinishReason: ch.FinishReason,
Reasoning: ch.Message.ReasoningContent,
Message: Message{
Role: ch.Message.Role,
ToolCalls: ch.Message.ToolCalls,
},
}
if out.Message.Role == "" {
out.Message.Role = RoleAssistant
}
if ch.Message.Content != nil {
content := *ch.Message.Content
if m := thinkRe.FindStringSubmatch(content); m != nil {
if out.Reasoning == "" {
out.Reasoning = strings.TrimSpace(m[1])
}
content = content[len(m[0]):]
}
out.Message.Content = content
}
if w.Usage != nil {
out.Usage = Usage{
PromptTokens: w.Usage.PromptTokens,
CompletionTokens: w.Usage.CompletionTokens,
TotalTokens: w.Usage.TotalTokens,
}
}
return out, false, nil
}
// HTTPError es una respuesta no-200 del servidor de inferencia. Permite al
// agente reaccionar a casos concretos (p.ej. servidor sin soporte de tools
// o contexto excedido).
type HTTPError struct {
URL string
Status int
Body string
}
func (e *HTTPError) Error() string {
return fmt.Sprintf("POST %s: HTTP %d: %s", e.URL, e.Status, e.Body)
}
// IsContextOverflow detecta, de forma heurística, errores por exceso de contexto.
func IsContextOverflow(err error) bool {
var he *HTTPError
if !errors.As(err, &he) || he.Status < 400 || he.Status > 500 {
return false
}
b := strings.ToLower(he.Body)
return strings.Contains(b, "context") && (strings.Contains(b, "exceed") || strings.Contains(b, "length") || strings.Contains(b, "too long") || strings.Contains(b, "overflow")) ||
strings.Contains(b, "too many tokens") || strings.Contains(b, "maximum context")
}
// IsToolsUnsupported detecta, de forma heurística, que el servidor rechaza
// el parámetro "tools".
func IsToolsUnsupported(err error) bool {
var he *HTTPError
if !errors.As(err, &he) || he.Status < 400 || he.Status > 501 {
return false
}
b := strings.ToLower(he.Body)
return strings.Contains(b, "tool") || strings.Contains(b, "function") || he.Status == 501
}
// DiscoverModel intenta averiguar el tamaño de contexto que usa el servidor,
// para cumplir CONTEXT_SIZE=0 sin inventar un número.
//
// Se consultan, en orden, formatos conocidos:
// - GET {base}/models: campos max_model_len (vLLM), context_length
// (OpenRouter, LM Studio) o meta.n_ctx_train (llama.cpp).
// - GET {origin}/props: default_generation_settings.n_ctx (llama.cpp),
// que es el contexto realmente configurado en el servidor y por tanto
// tiene prioridad sobre n_ctx_train.
//
// Si nada funciona, devuelve ContextSize 0 (UNKNOWN) sin error: no conocer
// el tamaño no debe impedir el arranque.
func (c *OpenAIClient) DiscoverModel(ctx context.Context) (ModelInfo, error) {
info := ModelInfo{ID: c.model}
var models struct {
Data []struct {
ID string `json:"id"`
MaxModelLen int `json:"max_model_len"`
ContextLength int `json:"context_length"`
Meta struct {
NCtxTrain int `json:"n_ctx_train"`
} `json:"meta"`
} `json:"data"`
}
modelsErr := c.getJSON(ctx, c.baseURL+"/models", &models)
if modelsErr == nil {
// Se usa la entrada con el ID exacto; si el servidor expone un único
// modelo (caso típico de llama.cpp), se acepta aunque el ID difiera.
idx := -1
for i, m := range models.Data {
if m.ID == c.model {
idx = i
break
}
}
if idx < 0 && len(models.Data) == 1 {
idx = 0
}
if idx >= 0 {
m := models.Data[idx]
switch {
case m.MaxModelLen > 0:
info.ContextSize, info.Source = m.MaxModelLen, "server /models max_model_len"
case m.ContextLength > 0:
info.ContextSize, info.Source = m.ContextLength, "server /models context_length"
case m.Meta.NCtxTrain > 0:
info.ContextSize, info.Source = m.Meta.NCtxTrain, "server /models meta.n_ctx_train"
}
}
}
var props struct {
DefaultGenerationSettings struct {
NCtx int `json:"n_ctx"`
} `json:"default_generation_settings"`
}
if err := c.getJSON(ctx, originOf(c.baseURL)+"/props", &props); err == nil && props.DefaultGenerationSettings.NCtx > 0 {
info.ContextSize, info.Source = props.DefaultGenerationSettings.NCtx, "server /props n_ctx"
}
if modelsErr != nil && info.ContextSize == 0 {
return info, modelsErr
}
return info, nil
}
func (c *OpenAIClient) getJSON(ctx context.Context, url string, v any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("failed to build request for %s: %w", url, err)
}
c.setHeaders(req)
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("GET %s: %w", url, err)
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
if err != nil {
return fmt.Errorf("failed to read %s: %w", url, err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode)
}
if err := json.Unmarshal(data, v); err != nil {
return fmt.Errorf("invalid JSON from %s: %w", url, err)
}
return nil
}
// setHeaders añade autenticación y User-Agent. La API key solo vive aquí.
func (c *OpenAIClient) setHeaders(r *http.Request) {
if c.apiKey != "" {
r.Header.Set("Authorization", "Bearer "+c.apiKey)
}
if c.userAgent != "" {
r.Header.Set("User-Agent", c.userAgent)
}
r.Header.Set("Accept", "application/json")
}
// isTransient decide si un error de red merece reintento.
func isTransient(err error) bool {
if errors.Is(err, context.Canceled) {
return false
}
var ne net.Error
if errors.As(err, &ne) {
return true
}
return errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF)
}
// originOf convierte "http://host:8080/v1" en "http://host:8080".
func originOf(base string) string {
if i := strings.Index(base, "://"); i >= 0 {
if j := strings.Index(base[i+3:], "/"); j >= 0 {
return base[:i+3+j]
}
}
return base
}
// snippet recorta cuerpos de error para mensajes legibles.
func snippet(b []byte) string {
s := strings.TrimSpace(string(b))
if len(s) > 300 {
s = s[:300] + "..."
}
return s
}