post inicial
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestClient(url, key string) *OpenAIClient {
|
||||
c := NewOpenAIClient(Options{BaseURL: url + "/v1", APIKey: key, Model: "qwen3", UserAgent: "sysagent/test", Timeout: 5 * time.Second})
|
||||
c.backoff = time.Millisecond
|
||||
return c
|
||||
}
|
||||
|
||||
func TestChat(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/chat/completions" || r.Method != http.MethodPost {
|
||||
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer sk-secret" {
|
||||
t.Errorf("Authorization = %q", got)
|
||||
}
|
||||
if got := r.Header.Get("User-Agent"); got != "sysagent/test" {
|
||||
t.Errorf("User-Agent = %q", got)
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req map[string]any
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req["model"] != "qwen3" || req["stream"] != false {
|
||||
t.Errorf("request = %v", req)
|
||||
}
|
||||
io.WriteString(w, `{"choices":[{"message":{"role":"assistant","content":"<think>hmm</think>\nHola"},"finish_reason":"stop"}],
|
||||
"usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12}}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := newTestClient(srv.URL, "sk-secret").Chat(context.Background(), ChatRequest{
|
||||
Messages: []Message{{Role: RoleUser, Content: "hola"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Message.Content != "Hola" {
|
||||
t.Errorf("content = %q (think block must be stripped)", resp.Message.Content)
|
||||
}
|
||||
if resp.Reasoning != "hmm" {
|
||||
t.Errorf("reasoning = %q", resp.Reasoning)
|
||||
}
|
||||
if resp.Usage.PromptTokens != 10 || resp.FinishReason != "stop" {
|
||||
t.Errorf("resp = %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatNoAuthHeaderWhenKeyEmpty(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "" {
|
||||
t.Error("Authorization header must be absent when API_KEY is empty")
|
||||
}
|
||||
io.WriteString(w, `{"choices":[{"message":{"content":"ok"}}]}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
if _, err := newTestClient(srv.URL, "").Chat(context.Background(), ChatRequest{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatRetriesTransient(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if calls.Add(1) < 3 {
|
||||
http.Error(w, "busy", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
io.WriteString(w, `{"choices":[{"message":{"content":"ok"}}]}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
resp, err := newTestClient(srv.URL, "").Chat(context.Background(), ChatRequest{})
|
||||
if err != nil || resp.Message.Content != "ok" || calls.Load() != 3 {
|
||||
t.Fatalf("resp=%+v err=%v calls=%d", resp, err, calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatDoesNotRetryClientError(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
http.Error(w, `{"error":{"message":"bad model"}}`, http.StatusBadRequest)
|
||||
}))
|
||||
defer srv.Close()
|
||||
_, err := newTestClient(srv.URL, "").Chat(context.Background(), ChatRequest{})
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTP 400") || calls.Load() != 1 {
|
||||
t.Fatalf("err=%v calls=%d", err, calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverModel(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/models":
|
||||
io.WriteString(w, `{"data":[{"id":"other","meta":{"n_ctx_train":131072}}]}`)
|
||||
case "/props":
|
||||
io.WriteString(w, `{"default_generation_settings":{"n_ctx":16384}}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
info, err := newTestClient(srv.URL, "").DiscoverModel(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.ContextSize != 16384 || !strings.Contains(info.Source, "/props") {
|
||||
t.Errorf("info = %+v (configured n_ctx must win over n_ctx_train)", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverModelUnknown(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v1/models" {
|
||||
io.WriteString(w, `{"data":[{"id":"qwen3"},{"id":"x"}]}`)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
info, err := newTestClient(srv.URL, "").DiscoverModel(context.Background())
|
||||
if err != nil || info.ContextSize != 0 {
|
||||
t.Fatalf("expected UNKNOWN (0) without error, got %+v err=%v", info, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Package llm abstrae el acceso al modelo de lenguaje.
|
||||
//
|
||||
// El resto del programa depende solo de la interfaz Client y de estos tipos,
|
||||
// no del proveedor concreto. La implementación incluida habla el dialecto
|
||||
// OpenAI (POST /chat/completions), que también implementan llama.cpp, vLLM,
|
||||
// Ollama, LM Studio y muchos otros servidores.
|
||||
package llm
|
||||
|
||||
import "context"
|
||||
|
||||
// Roles de mensaje del protocolo de chat.
|
||||
const (
|
||||
RoleSystem = "system"
|
||||
RoleUser = "user"
|
||||
RoleAssistant = "assistant"
|
||||
RoleTool = "tool"
|
||||
)
|
||||
|
||||
// Message es un mensaje de la conversación.
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Name string `json:"name,omitempty"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
}
|
||||
|
||||
// ToolCall es una invocación de herramienta propuesta por el modelo.
|
||||
// Los argumentos llegan como texto JSON sin validar: el registro de
|
||||
// herramientas los decodifica estrictamente antes de ejecutar nada.
|
||||
type ToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function FunctionCall `json:"function"`
|
||||
}
|
||||
|
||||
// FunctionCall contiene el nombre y los argumentos JSON (sin validar).
|
||||
type FunctionCall struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
}
|
||||
|
||||
// Tool describe una herramienta ofrecida al modelo.
|
||||
type Tool struct {
|
||||
Type string `json:"type"`
|
||||
Function ToolFunction `json:"function"`
|
||||
}
|
||||
|
||||
// ToolFunction es el esquema de una herramienta (JSON Schema en Parameters).
|
||||
type ToolFunction struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Parameters any `json:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
// ChatRequest es una petición independiente del proveedor.
|
||||
type ChatRequest struct {
|
||||
Messages []Message
|
||||
Tools []Tool
|
||||
MaxTokens int // 0: el servidor decide
|
||||
Temperature *float64 // nil: el servidor decide
|
||||
}
|
||||
|
||||
// Usage es el consumo de tokens reportado por el servidor.
|
||||
// Todos los campos son 0 si el servidor no lo reporta.
|
||||
type Usage struct {
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
TotalTokens int
|
||||
}
|
||||
|
||||
// ChatResponse es la respuesta del modelo.
|
||||
type ChatResponse struct {
|
||||
Message Message
|
||||
Reasoning string // razonamiento separado, si el servidor lo expone
|
||||
FinishReason string
|
||||
Usage Usage
|
||||
}
|
||||
|
||||
// Client es la interfaz que usa la lógica del agente.
|
||||
type Client interface {
|
||||
Chat(ctx context.Context, req ChatRequest) (ChatResponse, error)
|
||||
}
|
||||
|
||||
// ModelInfo es la información que se pudo obtener del servidor sobre el modelo.
|
||||
// ContextSize == 0 significa UNKNOWN.
|
||||
type ModelInfo struct {
|
||||
ID string
|
||||
ContextSize int
|
||||
Source string // de dónde se obtuvo ContextSize, para mostrarlo con honestidad
|
||||
}
|
||||
Reference in New Issue
Block a user