Replace go-openai with native client
Drop the github.com/sashabaranov/go-openai dependency in favor of an internal HTTP client. The LLM package is now split into: - llm.go: type definitions (Message, ChatRequest, ChatResponse, etc.) - client.go: client construction and configuration - client_completions.go: Completions and CompletionsStream methods - client_models.go: ListModels - client_tools.go: tool call execution CompletionsStream uses typed SSE events (delta, done, message) so the frontend can render progressive streaming output. Move tool.go and tool_test.go from internal/tool/ into internal/llm/ to co-locate the registry with the client that consumes it. Update job.go and service.go to use the new llm.Message types and Completions/CompletionsStream methods. Remove the Voice field from chat request/response (voice is now tracked via message metadata). Frontend: handle delta/done/message SSE events, render a streaming placeholder that receives progressive text deltas, and finalize with full message binding (reasoning, tool calls, actions).
This commit is contained in:
@@ -1,412 +1,262 @@
|
||||
// Package llm provides an OpenAI-compatible client for LLM interactions.
|
||||
// It handles chat completions with automatic tool call execution.
|
||||
// It handles chat completions with automatic tool call execution, where the
|
||||
// client iteratively sends messages to the LLM, executes any requested tool
|
||||
// calls, and continues until the LLM produces a final text response.
|
||||
//
|
||||
// The package defines the core types for constructing requests and parsing
|
||||
// responses: Message, ChatRequest, ChatResponse, and streaming types
|
||||
// such as StreamChunk and Delta.
|
||||
//
|
||||
// Tools are defined declaratively in YAML configuration, stored as Tool
|
||||
// structs in a Registry, and executed as subprocesses when the LLM invokes
|
||||
// them. The Registry also converts tool definitions into the API format for
|
||||
// inclusion in ChatRequest bodies.
|
||||
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.chimeric.al/chimerical/odidere/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"`
|
||||
// SystemMessage is prepended to all conversations.
|
||||
SystemMessage string `yaml:"system_message"`
|
||||
// Timeout is the maximum duration for a query (e.g., "5m").
|
||||
// Defaults to 5 minutes if empty.
|
||||
Timeout string `yaml:"timeout"`
|
||||
// URL is the base URL of the OpenAI-compatible API endpoint.
|
||||
URL string `yaml:"url"`
|
||||
// Role constants for messages.
|
||||
const (
|
||||
RoleSystem = "system"
|
||||
RoleUser = "user"
|
||||
RoleAssistant = "assistant"
|
||||
RoleTool = "tool"
|
||||
RoleDeveloper = "developer"
|
||||
)
|
||||
|
||||
// APITool defines a tool available to the LLM in the API request format.
|
||||
type APITool struct {
|
||||
Type string `json:"type"` // "function"
|
||||
Function *FunctionDef `json:"function,omitempty"`
|
||||
}
|
||||
|
||||
// Validate checks that required configuration values are present and valid.
|
||||
func (cfg Config) Validate() error {
|
||||
if cfg.Timeout != "" {
|
||||
if _, err := time.ParseDuration(cfg.Timeout); err != nil {
|
||||
return fmt.Errorf("invalid timeout: %w", err)
|
||||
// ChatRequest is the request body for chat completions.
|
||||
type ChatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
Temperature *float32 `json:"temperature,omitempty"`
|
||||
TopP *float32 `json:"top_p,omitempty"`
|
||||
N int `json:"n,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Stop []string `json:"stop,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
|
||||
ResponseFormat *ResponseFormat `json:"response_format,omitempty"`
|
||||
Seed *int `json:"seed,omitempty"`
|
||||
PresencePenalty *float32 `json:"presence_penalty,omitempty"`
|
||||
FrequencyPenalty *float32 `json:"frequency_penalty,omitempty"`
|
||||
LogProbs bool `json:"logprobs,omitempty"`
|
||||
TopLogProbs int `json:"top_logprobs,omitempty"`
|
||||
Tools []APITool `json:"tools,omitempty"`
|
||||
ToolChoice any `json:"tool_choice,omitempty"`
|
||||
StreamOptions *StreamOptions `json:"stream_options,omitempty"`
|
||||
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"`
|
||||
ReasoningEffort string `json:"reasoning_effort,omitempty"`
|
||||
ServiceTier string `json:"service_tier,omitempty"`
|
||||
}
|
||||
|
||||
// ChatResponse is the non-streaming response from the API.
|
||||
type ChatResponse struct {
|
||||
ID string `json:"id"`
|
||||
Model string `json:"model"`
|
||||
Choices []Choice `json:"choices"`
|
||||
}
|
||||
|
||||
// Choice is a single choice in a ChatResponse.
|
||||
type Choice struct {
|
||||
Index int `json:"index"`
|
||||
Message Message `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
}
|
||||
|
||||
// ContentType identifies the kind of data in a ContentPart.
|
||||
//
|
||||
// User messages (ChatCompletionContentPart) support: text, image_url,
|
||||
// input_audio, file.
|
||||
//
|
||||
// Assistant messages (ChatCompletionAssistantMessageParam) support:
|
||||
// text, refusal.
|
||||
//
|
||||
// See: https://platform.openai.com/docs/api-reference/chat/create
|
||||
type ContentType string
|
||||
|
||||
const (
|
||||
ContentTypeText ContentType = "text"
|
||||
ContentTypeImageURL ContentType = "image_url"
|
||||
ContentTypeInputAudio ContentType = "input_audio"
|
||||
ContentTypeFile ContentType = "file"
|
||||
ContentTypeRefusal ContentType = "refusal"
|
||||
)
|
||||
|
||||
// ContentPart represents a single part of a multi-content message.
|
||||
type ContentPart struct {
|
||||
Type ContentType `json:"type"`
|
||||
Text string `json:"text"`
|
||||
ImageURL *ImageURL `json:"image_url,omitempty"`
|
||||
InputAudio *InputAudio `json:"input_audio,omitempty"`
|
||||
File *FileInput `json:"file,omitempty"`
|
||||
Refusal string `json:"refusal"`
|
||||
}
|
||||
|
||||
// Delta is a partial message update in a streaming response.
|
||||
type Delta struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Refusal string `json:"refusal,omitempty"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
}
|
||||
|
||||
// FileInput holds file data for file content parts.
|
||||
type FileInput struct {
|
||||
FileData string `json:"file_data,omitempty"`
|
||||
FileID string `json:"file_id,omitempty"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
}
|
||||
|
||||
// Function holds the name and arguments of a tool call.
|
||||
type Function struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Arguments string `json:"arguments,omitempty"`
|
||||
}
|
||||
|
||||
// FunctionDef defines a function that can be called by the LLM.
|
||||
type FunctionDef struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Parameters any `json:"parameters"` // JSON schema
|
||||
Strict bool `json:"strict,omitempty"`
|
||||
}
|
||||
|
||||
// ImageURL holds an image reference for multi-modal messages.
|
||||
type ImageURL struct {
|
||||
URL string `json:"url"`
|
||||
Detail string `json:"detail,omitempty"` // "auto", "low", "high"
|
||||
}
|
||||
|
||||
// InputAudio holds audio data for multi-modal messages.
|
||||
type InputAudio struct {
|
||||
Data string `json:"data"`
|
||||
Format string `json:"format"` // "wav", "mp3"
|
||||
}
|
||||
|
||||
// Message represents a chat message in a conversation.
|
||||
// Content is always stored as []ContentPart internally.
|
||||
// A custom UnmarshalJSON handles the JSON polymorphism where content
|
||||
// can arrive as either a string or an array of parts.
|
||||
//
|
||||
// Refusal handling:
|
||||
//
|
||||
// In API responses (ChatCompletionMessage), the model sends refusal as a
|
||||
// top-level field alongside content:
|
||||
//
|
||||
// "content": "text response"
|
||||
// "refusal": "I can't help with that"
|
||||
//
|
||||
// Only one of content or refusal will be non-empty.
|
||||
//
|
||||
// In API requests (ChatCompletionAssistantMessageParam), the model can
|
||||
// receive refusal as a content part with type "refusal" in the content
|
||||
// array. Our ContentPart type supports this for completeness.
|
||||
//
|
||||
// See: https://platform.openai.com/docs/api-reference/chat/create
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
ContentParts []ContentPart `json:"content,omitempty"`
|
||||
Refusal string `json:"refusal,omitempty"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
}
|
||||
|
||||
// Text returns the concatenated text from all text content parts.
|
||||
func (m *Message) Text() string {
|
||||
var b strings.Builder
|
||||
for _, p := range m.ContentParts {
|
||||
if p.Type == ContentTypeText {
|
||||
b.WriteString(p.Text)
|
||||
}
|
||||
}
|
||||
if cfg.URL == "" {
|
||||
return fmt.Errorf("missing URL")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// UnmarshalJSON handles the polymorphic content field which can be
|
||||
// either a string or an array of ContentPart objects.
|
||||
func (m *Message) UnmarshalJSON(data []byte) error {
|
||||
type Alias Message
|
||||
aux := &struct {
|
||||
Content any `json:"content"`
|
||||
*Alias
|
||||
}{
|
||||
Alias: (*Alias)(m),
|
||||
}
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
switch c := aux.Content.(type) {
|
||||
case string:
|
||||
m.ContentParts = []ContentPart{{Type: ContentTypeText, Text: c}}
|
||||
case []any:
|
||||
for _, raw := range c {
|
||||
partBytes, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var part ContentPart
|
||||
if err := json.Unmarshal(partBytes, &part); err != nil {
|
||||
return err
|
||||
}
|
||||
m.ContentParts = append(m.ContentParts, part)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Client wraps an OpenAI-compatible client with tool execution support.
|
||||
type Client struct {
|
||||
client *openai.Client
|
||||
log *slog.Logger
|
||||
registry *tool.Registry
|
||||
systemMessage string
|
||||
timeout time.Duration
|
||||
tools []openai.Tool
|
||||
// Model represents an available model from the API.
|
||||
type Model struct {
|
||||
ID string `json:"id"`
|
||||
Created int64 `json:"created"`
|
||||
Object string `json:"object"`
|
||||
OwnedBy string `json:"owned_by"`
|
||||
}
|
||||
|
||||
// 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: %w", err)
|
||||
}
|
||||
|
||||
llm := &Client{
|
||||
log: log,
|
||||
systemMessage: cfg.SystemMessage,
|
||||
registry: registry,
|
||||
}
|
||||
|
||||
if cfg.Timeout == "" {
|
||||
llm.timeout = 5 * 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() {
|
||||
t, _ := llm.registry.Get(name)
|
||||
llm.tools = append(llm.tools, t.OpenAI())
|
||||
}
|
||||
}
|
||||
|
||||
return llm, nil
|
||||
// ModelsResponse is the response for GET /models.
|
||||
type ModelsResponse struct {
|
||||
Object string `json:"object"`
|
||||
Data []Model `json:"data"`
|
||||
}
|
||||
|
||||
// ListModels returns available models from the LLM server.
|
||||
func (c *Client) ListModels(ctx context.Context) ([]openai.Model, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, c.timeout)
|
||||
defer cancel()
|
||||
|
||||
res, err := c.client.ListModels(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing models: %w", err)
|
||||
}
|
||||
|
||||
return res.Models, nil
|
||||
// ResponseFormat specifies the format of the response.
|
||||
type ResponseFormat struct {
|
||||
Type string `json:"type"`
|
||||
JSONSchema any `json:"json_schema,omitempty"`
|
||||
}
|
||||
|
||||
// ErrMaxIterations is returned when the agent loop exceeds the configured
|
||||
// maximum number of iterations.
|
||||
var ErrMaxIterations = fmt.Errorf("max iterations exceeded")
|
||||
|
||||
// Query sends messages to the LLM using the specified model.
|
||||
// If systemMessage is non-empty, it overrides the configured system message.
|
||||
// maxIterations caps the number of agent loop iterations; a value of 0
|
||||
// means unlimited (no cap).
|
||||
// Returns all messages generated during the query, including tool calls
|
||||
// and tool results. The final message is the last element in the slice.
|
||||
func (c *Client) Query(
|
||||
ctx context.Context,
|
||||
messages []openai.ChatCompletionMessage,
|
||||
model string,
|
||||
systemMessage string,
|
||||
maxIterations int,
|
||||
) ([]openai.ChatCompletionMessage, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, c.timeout)
|
||||
defer cancel()
|
||||
|
||||
// Use per-request system message if provided, otherwise fall back to config.
|
||||
effectiveMessage := c.systemMessage
|
||||
if systemMessage != "" {
|
||||
effectiveMessage = systemMessage
|
||||
}
|
||||
|
||||
// Prepend system message, if configured and not already present.
|
||||
if effectiveMessage != "" && (len(messages) == 0 ||
|
||||
messages[0].Role != openai.ChatMessageRoleSystem) {
|
||||
messages = append(
|
||||
[]openai.ChatCompletionMessage{{
|
||||
Role: openai.ChatMessageRoleSystem,
|
||||
Content: effectiveMessage,
|
||||
}},
|
||||
messages...,
|
||||
)
|
||||
}
|
||||
|
||||
// Track messages generated during this query.
|
||||
var generated []openai.ChatCompletionMessage
|
||||
|
||||
// Loop for tool calls. maxIterations == 0 means unlimited.
|
||||
for i := 0; maxIterations == 0 || i < maxIterations; i++ {
|
||||
req := openai.ChatCompletionRequest{
|
||||
Model: model,
|
||||
Messages: messages,
|
||||
}
|
||||
if len(c.tools) > 0 {
|
||||
req.Tools = c.tools
|
||||
}
|
||||
|
||||
res, err := c.client.CreateChatCompletion(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("chat completion: %w", err)
|
||||
}
|
||||
if len(res.Choices) == 0 {
|
||||
return nil, 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 {
|
||||
generated = append(generated, message)
|
||||
return generated, nil
|
||||
}
|
||||
|
||||
// Add assistant message with tool calls to history.
|
||||
generated = append(generated, message)
|
||||
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),
|
||||
)
|
||||
|
||||
result, err := c.registry.Execute(
|
||||
ctx, tc.Function.Name, tc.Function.Arguments,
|
||||
)
|
||||
if err != nil {
|
||||
c.log.Error(
|
||||
"failed to call tool",
|
||||
slog.Any("error", err),
|
||||
slog.String("name", tc.Function.Name),
|
||||
)
|
||||
result = fmt.Sprintf(
|
||||
`{"ok": false, "error": %q}`, err,
|
||||
)
|
||||
} else {
|
||||
c.log.Info(
|
||||
"called tool",
|
||||
slog.String("name", tc.Function.Name),
|
||||
)
|
||||
}
|
||||
|
||||
// Content cannot be empty.
|
||||
if strings.TrimSpace(result) == "" {
|
||||
result = `{"ok": true, "result": null}`
|
||||
}
|
||||
|
||||
// Add tool result to messages.
|
||||
toolResult := openai.ChatCompletionMessage{
|
||||
Role: openai.ChatMessageRoleTool,
|
||||
Content: result,
|
||||
Name: tc.Function.Name,
|
||||
ToolCallID: tc.ID,
|
||||
}
|
||||
generated = append(generated, toolResult)
|
||||
messages = append(messages, toolResult)
|
||||
}
|
||||
// Loop to get LLM's response after tool execution.
|
||||
}
|
||||
|
||||
return generated, ErrMaxIterations
|
||||
// StreamChunk is a single SSE chunk from a streaming response.
|
||||
type StreamChunk struct {
|
||||
ID string `json:"id"`
|
||||
Model string `json:"model"`
|
||||
Choices []struct {
|
||||
Index int `json:"index"`
|
||||
Delta Delta `json:"delta"`
|
||||
FinishReason string `json:"finish_reason,omitempty"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
// StreamEvent wraps a ChatCompletionMessage produced during streaming.
|
||||
type StreamEvent struct {
|
||||
Message openai.ChatCompletionMessage
|
||||
// StreamOptions controls streaming behavior.
|
||||
type StreamOptions struct {
|
||||
IncludeUsage bool `json:"include_usage,omitempty"`
|
||||
}
|
||||
|
||||
// QueryStream sends messages to the LLM using the specified model and
|
||||
// streams results. Each complete message (assistant reply, tool call,
|
||||
// tool result) is sent to the events channel as it becomes available.
|
||||
// The channel is closed before returning.
|
||||
// If systemMessage is non-empty, it overrides the configured system message.
|
||||
// maxIterations caps the number of agent loop iterations; a value of 0
|
||||
// means unlimited (no cap).
|
||||
// Returns ErrMaxIterations if the iteration limit is exceeded.
|
||||
func (c *Client) QueryStream(
|
||||
ctx context.Context,
|
||||
messages []openai.ChatCompletionMessage,
|
||||
model string,
|
||||
systemMessage string,
|
||||
maxIterations int,
|
||||
events chan<- StreamEvent,
|
||||
) error {
|
||||
defer close(events)
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, c.timeout)
|
||||
defer cancel()
|
||||
|
||||
// Use per-request system message if provided, otherwise fall back to config.
|
||||
effectiveMessage := c.systemMessage
|
||||
if systemMessage != "" {
|
||||
effectiveMessage = systemMessage
|
||||
}
|
||||
|
||||
// Prepend system message, if configured and not already present.
|
||||
if effectiveMessage != "" && (len(messages) == 0 ||
|
||||
messages[0].Role != openai.ChatMessageRoleSystem) {
|
||||
messages = append(
|
||||
[]openai.ChatCompletionMessage{{
|
||||
Role: openai.ChatMessageRoleSystem,
|
||||
Content: effectiveMessage,
|
||||
}},
|
||||
messages...,
|
||||
)
|
||||
}
|
||||
|
||||
// Loop for tool calls. maxIterations == 0 means unlimited.
|
||||
for i := 0; maxIterations == 0 || i < maxIterations; i++ {
|
||||
req := openai.ChatCompletionRequest{
|
||||
Model: model,
|
||||
Messages: messages,
|
||||
}
|
||||
if len(c.tools) > 0 {
|
||||
req.Tools = c.tools
|
||||
}
|
||||
|
||||
stream, err := c.client.CreateChatCompletionStream(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("chat completion stream: %w", err)
|
||||
}
|
||||
|
||||
// Accumulate the streamed response.
|
||||
var (
|
||||
content strings.Builder
|
||||
reasoning strings.Builder
|
||||
toolCalls []openai.ToolCall
|
||||
role string
|
||||
)
|
||||
for {
|
||||
chunk, err := stream.Recv()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
stream.Close()
|
||||
return fmt.Errorf("stream recv: %w", err)
|
||||
}
|
||||
if len(chunk.Choices) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check the first Choice. Only one is expected, since
|
||||
// our request does not set N > 1.
|
||||
delta := chunk.Choices[0].Delta
|
||||
if delta.Role != "" {
|
||||
role = delta.Role
|
||||
}
|
||||
if delta.Content != "" {
|
||||
content.WriteString(delta.Content)
|
||||
}
|
||||
if delta.ReasoningContent != "" {
|
||||
reasoning.WriteString(delta.ReasoningContent)
|
||||
}
|
||||
|
||||
// Accumulate tool call deltas by index.
|
||||
for _, tc := range delta.ToolCalls {
|
||||
i := 0
|
||||
if tc.Index != nil {
|
||||
i = *tc.Index
|
||||
}
|
||||
// Grow the slice as needed.
|
||||
for len(toolCalls) <= i {
|
||||
toolCalls = append(
|
||||
toolCalls,
|
||||
openai.ToolCall{
|
||||
Type: openai.ToolTypeFunction,
|
||||
},
|
||||
)
|
||||
}
|
||||
if tc.ID != "" {
|
||||
toolCalls[i].ID = tc.ID
|
||||
}
|
||||
if tc.Function.Name != "" {
|
||||
toolCalls[i].Function.Name +=
|
||||
tc.Function.Name
|
||||
}
|
||||
if tc.Function.Arguments != "" {
|
||||
toolCalls[i].Function.Arguments +=
|
||||
tc.Function.Arguments
|
||||
}
|
||||
}
|
||||
}
|
||||
stream.Close()
|
||||
|
||||
// Build the complete message from accumulated buffers.
|
||||
message := openai.ChatCompletionMessage{
|
||||
Role: role,
|
||||
Content: content.String(),
|
||||
ReasoningContent: reasoning.String(),
|
||||
ToolCalls: toolCalls,
|
||||
}
|
||||
events <- StreamEvent{Message: message}
|
||||
|
||||
// If no tool calls, we're done.
|
||||
if len(toolCalls) == 0 {
|
||||
return 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),
|
||||
)
|
||||
|
||||
result, err := c.registry.Execute(
|
||||
ctx, tc.Function.Name, tc.Function.Arguments,
|
||||
)
|
||||
if err != nil {
|
||||
c.log.Error(
|
||||
"failed to call tool",
|
||||
slog.Any("error", err),
|
||||
slog.String("name", tc.Function.Name),
|
||||
)
|
||||
result = fmt.Sprintf(
|
||||
`{"ok": false, "error": %q}`, err,
|
||||
)
|
||||
}
|
||||
// Content cannot be empty.
|
||||
if strings.TrimSpace(result) == "" {
|
||||
result = `{"ok": true, "result": null}`
|
||||
}
|
||||
|
||||
// Add tool result to messages.
|
||||
toolResult := openai.ChatCompletionMessage{
|
||||
Content: result,
|
||||
Name: tc.Function.Name,
|
||||
Role: openai.ChatMessageRoleTool,
|
||||
ToolCallID: tc.ID,
|
||||
}
|
||||
messages = append(messages, toolResult)
|
||||
events <- StreamEvent{Message: toolResult}
|
||||
}
|
||||
// Loop to get LLM's response after tool execution.
|
||||
}
|
||||
|
||||
return ErrMaxIterations
|
||||
// ToolCall represents a tool call made by the LLM.
|
||||
type ToolCall struct {
|
||||
Index *int `json:"index,omitempty"` // streaming only
|
||||
ID string `json:"id,omitempty"`
|
||||
Type string `json:"type"` // "function"
|
||||
Function Function `json:"function"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user