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 ... que algunos modelos (Qwen3, // DeepSeek-R1) incluyen en el contenido cuando el servidor no los separa. var thinkRe = regexp.MustCompile(`(?s)^\s*(.*?)\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 }