Files
raven/internal/llm/llm.go

208 lines
5.2 KiB
Go
Raw Permalink Normal View History

// Package llm provides an OpenAI-compatible client for LLM interactions.
// It handles chat completions with automatic tool call execution.
package llm
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
2026-02-21 19:40:35 +00:00
"code.chimeric.al/chimerical/raven/internal/message"
"code.chimeric.al/chimerical/raven/internal/tool"
openai "github.com/sashabaranov/go-openai"
)
// Config holds the configuration for an LLM client.
type Config struct {
// Key is the API key for authentication.
Key string `yaml:"key"`
// Model is the model identifier (e.g., "gpt-oss-120b", "qwen3-32b").
Model string `yaml:"model"`
// SystemPrompt is prepended to all conversations.
SystemPrompt string `yaml:"system_prompt"`
// Timeout is the maximum duration for a query (e.g., "15m").
// Defaults to 15 minutes if empty.
Timeout string `yaml:"timeout"`
// URL is the base URL of the OpenAI-compatible API endpoint.
URL string `yaml:"url"`
}
// Validate checks that required configuration values are present and valid.
func (cfg Config) Validate() error {
if cfg.Model == "" {
return fmt.Errorf("missing model")
}
if cfg.Timeout != "" {
if _, err := time.ParseDuration(cfg.Timeout); err != nil {
return fmt.Errorf("invalid duration")
}
}
if cfg.URL == "" {
return fmt.Errorf("missing URL")
}
return nil
}
// Client wraps an OpenAI-compatible client with tool execution support.
type Client struct {
client *openai.Client
log *slog.Logger
model string
registry *tool.Registry
systemPrompt string
timeout time.Duration
tools []openai.Tool
}
// NewClient creates a new LLM client with the provided configuration.
// The registry is optional; if nil, tool calling is disabled.
func NewClient(
cfg Config,
registry *tool.Registry,
log *slog.Logger,
) (*Client, error) {
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid config: %v", err)
}
var llm = &Client{
log: log,
model: cfg.Model,
systemPrompt: cfg.SystemPrompt,
registry: registry,
}
if cfg.Timeout == "" {
llm.timeout = time.Duration(15 * time.Minute)
} else {
d, err := time.ParseDuration(cfg.Timeout)
if err != nil {
return nil, fmt.Errorf("parse timeout: %v", err)
}
llm.timeout = d
}
// Setup client.
clientConfig := openai.DefaultConfig(cfg.Key)
clientConfig.BaseURL = cfg.URL
llm.client = openai.NewClientWithConfig(clientConfig)
// Parse tools.
if llm.registry != nil {
for _, name := range llm.registry.List() {
tool, _ := llm.registry.Get(name)
llm.tools = append(llm.tools, tool.OpenAI())
}
}
return llm, nil
}
// Query sends a message to the LLM and returns its response.
// It automatically executes any tool calls requested by the model,
// looping until the model returns a final text response.
// Supports multimodal content (text and images) via the Message type.
func (c *Client) Query(
ctx context.Context, msg *message.Message,
) (string, error) {
ctx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
// Build user message from email content.
// Uses MultiContent to support both text and image parts.
userMessage := openai.ChatCompletionMessage{
Role: openai.ChatMessageRoleUser,
MultiContent: msg.ToOpenAIMessages(),
}
// Set the system message and the user prompt.
messages := []openai.ChatCompletionMessage{
{
Role: openai.ChatMessageRoleSystem,
Content: c.systemPrompt,
},
userMessage,
}
// Loop for tool calls.
for {
req := openai.ChatCompletionRequest{
Model: c.model,
Messages: messages,
}
if len(c.tools) > 0 {
req.Tools = c.tools
}
res, err := c.client.CreateChatCompletion(ctx, req)
if err != nil {
return "", fmt.Errorf("chat completion: %w", err)
}
if len(res.Choices) == 0 {
return "", fmt.Errorf("no response choices returned")
}
choice := res.Choices[0]
message := choice.Message
// If no tool calls, we're done.
if len(message.ToolCalls) == 0 {
return message.Content, nil
}
// Add assistant message with tool calls to history.
messages = append(messages, message)
// Process each tool call.
for _, tc := range message.ToolCalls {
c.log.InfoContext(
ctx,
"calling tool",
slog.String("name", tc.Function.Name),
slog.String("args", tc.Function.Arguments),
)
res, err := c.registry.Execute(
ctx, tc.Function.Name, tc.Function.Arguments,
)
if err != nil {
// Return error to LLM so it can recover.
c.log.Error(
"failed to call tool",
slog.Any("error", err),
slog.String("name", tc.Function.Name),
)
// Assume JSON is a more helpful response
// for an LLM.
res = fmt.Sprintf(
`{"ok": false,"error": %q}`, err,
)
} else {
c.log.Info(
"called tool",
slog.String("name", tc.Function.Name),
)
}
// Content cannot be empty.
// Assume JSON is better than OK or (no output).
if strings.TrimSpace(res) == "" {
res = `{"ok":true,"result":null}`
}
// Add tool result to messages.
messages = append(
messages,
openai.ChatCompletionMessage{
Role: openai.ChatMessageRoleTool,
Content: res,
ToolCallID: tc.ID,
},
)
}
// Loop to get LLM's response after tool execution.
}
}