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:
dwrz
2026-06-26 14:22:05 +00:00
parent e480538ec2
commit f6fb21d40c
56 changed files with 1671 additions and 7854 deletions

82
internal/llm/client.go Normal file
View File

@@ -0,0 +1,82 @@
package llm
import (
"fmt"
"log/slog"
"net/http"
"time"
)
// 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"`
}
// 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)
}
}
if cfg.URL == "" {
return fmt.Errorf("missing URL")
}
return nil
}
// Client wraps an OpenAI-compatible client with tool execution support.
type Client struct {
baseURL string
httpc *http.Client
key string
log *slog.Logger
registry *Registry
systemMessage string
timeout time.Duration
tools []APITool
}
// 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 *Registry,
log *slog.Logger,
) (*Client, error) {
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
timeout := 5 * time.Minute
if cfg.Timeout != "" {
d, err := time.ParseDuration(cfg.Timeout)
if err != nil {
return nil, fmt.Errorf("parse timeout: %v", err)
}
timeout = d
}
llm := &Client{
baseURL: cfg.URL,
key: cfg.Key,
log: log,
systemMessage: cfg.SystemMessage,
registry: registry,
httpc: &http.Client{},
timeout: timeout,
}
// Convert tools from registry.
llm.tools = registry.ToAPI()
return llm, nil
}

View File

@@ -0,0 +1,482 @@
package llm
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"slices"
"strings"
)
// ErrMaxIterations is returned when the agent loop exceeds the configured
// maximum number of iterations.
var ErrMaxIterations = fmt.Errorf("max iterations exceeded")
// Completions 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 completions request, including
// tool calls and tool results.
func (c *Client) Completions(
ctx context.Context,
model string,
systemMessage string,
maxIterations int,
messages []Message,
) ([]Message, error) {
ctx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
// Use request system message if provided, config default otherwise.
smsg := c.systemMessage
if systemMessage != "" {
smsg = systemMessage
}
// Prepend system message, if configured and not already present.
if smsg != "" &&
(len(messages) == 0 || messages[0].Role != RoleSystem) {
messages = append(
[]Message{{
Role: RoleSystem,
ContentParts: []ContentPart{{
Type: ContentTypeText,
Text: smsg,
}},
}},
messages...,
)
}
// Remember the starting length so we can return only the messages
// generated during this call (the suffix of the conversation history).
start := len(messages)
// Loop for tool calls. maxIterations == 0 means unlimited.
for i := 0; maxIterations == 0 || i < maxIterations; i++ {
cr, err := c.completions(ctx, model, messages)
if err != nil {
return nil, fmt.Errorf("completions: %w", err)
}
// Use first choice for tool call loop.
message := &cr.Choices[0].Message
messages = append(messages, *message)
if len(message.ToolCalls) == 0 {
return messages[start:], nil
}
toolResults, err := c.callTools(ctx, *message)
if err != nil {
return nil, fmt.Errorf("completions: %w", err)
}
messages = append(messages, toolResults...)
}
return messages[start:], ErrMaxIterations
}
// completions sends a single non-streaming request and returns the
// full API response.
func (c *Client) completions(
ctx context.Context, model string, messages []Message,
) (*ChatResponse, error) {
body, err := json.Marshal(ChatRequest{
Model: model,
Messages: messages,
Tools: c.tools,
})
if err != nil {
return nil, fmt.Errorf(
"completions: marshal request body: %w", err,
)
}
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
c.baseURL+"/chat/completions",
bytes.NewReader(body),
)
if err != nil {
return nil, fmt.Errorf("completions: create request: %w", err)
}
if c.key != "" {
req.Header.Set("Authorization", "Bearer "+c.key)
}
req.Header.Set("Content-Type", "application/json")
res, err := c.httpc.Do(req)
if err != nil {
return nil, fmt.Errorf("completions: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
body, err := io.ReadAll(res.Body)
if err != nil {
c.log.ErrorContext(
ctx,
"completions: failed to read error response body",
slog.Any("error", err),
)
return nil, fmt.Errorf(
"completions: status code %d", res.StatusCode,
)
}
var apiError struct {
Error *struct {
Type string `json:"type"`
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(
body, &apiError,
); err == nil && apiError.Error != nil {
c.log.ErrorContext(
ctx,
"completions: request error",
slog.Int("status", res.StatusCode),
slog.String("code", apiError.Error.Code),
slog.String(
"message", apiError.Error.Message,
),
slog.String("type", apiError.Error.Type),
)
return nil, fmt.Errorf(
"completions: %s: %s",
apiError.Error.Code,
apiError.Error.Message,
)
}
c.log.ErrorContext(
ctx,
"completions: API error",
slog.Int("status", res.StatusCode),
slog.String("body", string(body)),
)
return nil, fmt.Errorf(
"completions: %d: %s",
res.StatusCode, string(body),
)
}
var cr = &ChatResponse{}
if err := json.NewDecoder(res.Body).Decode(cr); err != nil {
return nil, fmt.Errorf("completions: decode response: %w", err)
}
if len(cr.Choices) == 0 {
return nil, fmt.Errorf(
"completions: no response choices returned",
)
}
return cr, nil
}
// StreamEvent wraps a message or delta produced during streaming.
type StreamEvent struct {
// Type identifies the event kind: delta, done, or message.
Type string
// Delta is a text fragment from the assistant response.
Delta string
// Message is the complete chat completion message.
Message Message
}
const (
streamEventDelta = "delta"
streamEventDone = "done"
streamEventMessage = "message"
)
// CompletionsStream 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) CompletionsStream(
ctx context.Context,
model string,
systemMessage string,
maxIterations int,
messages []Message,
events chan<- StreamEvent,
) error {
defer close(events)
ctx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
// Use request system message if provided, config default otherwise.
smsg := c.systemMessage
if systemMessage != "" {
smsg = systemMessage
}
// Prepend system message, if configured and not already present.
if smsg != "" &&
(len(messages) == 0 || messages[0].Role != RoleSystem) {
messages = append(
[]Message{{
Role: RoleSystem,
ContentParts: []ContentPart{
{Type: ContentTypeText, Text: smsg},
},
}},
messages...,
)
}
// Loop for tool calls. maxIterations == 0 means unlimited.
for i := 0; maxIterations == 0 || i < maxIterations; i++ {
choices, err := c.completionsStream(ctx, messages, model, events)
if err != nil {
return fmt.Errorf("completions stream: %w", err)
}
// Use first choice for tool call loop.
message := choices[0]
if len(message.ToolCalls) == 0 {
return nil
}
toolResults, err := c.callTools(ctx, message)
if err != nil {
return fmt.Errorf("completions stream: %w", err)
}
for _, tr := range toolResults {
events <- StreamEvent{
Type: streamEventMessage,
Message: tr,
}
}
messages = append(messages, choices...)
messages = append(messages, toolResults...)
}
return ErrMaxIterations
}
// streamAccum accumulates deltas for a single choice in a streaming response.
type streamAccum struct {
content strings.Builder
reasoning strings.Builder
refusal strings.Builder
role string
toolCalls []ToolCall
}
// completionsStream sends a single streaming request, accumulates the
// response, and returns all assistant messages from the response choices.
// Sends each message to the events channel.
func (c *Client) completionsStream(ctx context.Context, messages []Message, model string, events chan<- StreamEvent) ([]Message, error) {
body, err := json.Marshal(ChatRequest{
Model: model,
Messages: messages,
Stream: true,
Tools: c.tools,
})
if err != nil {
return nil, fmt.Errorf(
"completions stream: marshal request body: %w", err,
)
}
req, err := http.NewRequestWithContext(
ctx, http.MethodPost,
c.baseURL+"/chat/completions",
bytes.NewReader(body),
)
if err != nil {
return nil, fmt.Errorf(
"completions stream: create request: %w", err,
)
}
if c.key != "" {
req.Header.Set("Authorization", "Bearer "+c.key)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
res, err := c.httpc.Do(req)
if err != nil {
return nil, fmt.Errorf("completions stream: %w", err)
}
if res.StatusCode != http.StatusOK {
body, err := io.ReadAll(res.Body)
res.Body.Close()
if err != nil {
c.log.ErrorContext(
ctx,
"completions stream: failed to read error response body",
slog.Any("error", err),
)
return nil, fmt.Errorf(
"completions stream: status code %d",
res.StatusCode,
)
}
var apiError struct {
Error *struct {
Type string `json:"type"`
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(
body, &apiError,
); err == nil && apiError.Error != nil {
c.log.ErrorContext(
ctx,
"completions stream: request error",
slog.Int("status", res.StatusCode),
slog.String("code", apiError.Error.Code),
slog.String(
"message", apiError.Error.Message,
),
slog.String("type", apiError.Error.Type),
)
return nil, fmt.Errorf(
"completions stream: %s: %s",
apiError.Error.Code,
apiError.Error.Message,
)
}
c.log.ErrorContext(
ctx,
"completions stream: API error",
slog.Int("status", res.StatusCode),
slog.String("body", string(body)),
)
return nil, fmt.Errorf(
"completions stream: %d: %s",
res.StatusCode, string(body),
)
}
// Accumulate the streamed response per choice index.
accums := make(map[int]*streamAccum)
scanner := bufio.NewScanner(res.Body)
for scanner.Scan() {
line := scanner.Text()
// Skip empty lines and comments.
if line == "" || line == ":" {
continue
}
// Parse data lines.
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
// [DONE] signals end of stream.
if data == "[DONE]" {
break
}
var chunk StreamChunk
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
continue
}
for _, ch := range chunk.Choices {
acc, ok := accums[ch.Index]
if !ok {
acc = &streamAccum{}
accums[ch.Index] = acc
}
delta := ch.Delta
if delta.Role != "" {
acc.role = delta.Role
}
if delta.Content != "" {
acc.content.WriteString(delta.Content)
events <- StreamEvent{
Type: streamEventDelta,
Delta: delta.Content,
}
}
if delta.Refusal != "" {
acc.refusal.WriteString(delta.Refusal)
}
if delta.ReasoningContent != "" {
acc.reasoning.WriteString(
delta.ReasoningContent,
)
}
// Accumulate tool call deltas by index.
for _, tc := range delta.ToolCalls {
idx := 0
if tc.Index != nil {
idx = *tc.Index
}
// Grow the slice as needed.
for len(acc.toolCalls) <= idx {
acc.toolCalls = append(
acc.toolCalls,
ToolCall{Type: "function"},
)
}
if tc.ID != "" {
acc.toolCalls[idx].ID = tc.ID
}
if tc.Function.Name != "" {
acc.toolCalls[idx].Function.Name +=
tc.Function.Name
}
if tc.Function.Arguments != "" {
acc.toolCalls[idx].Function.Arguments +=
tc.Function.Arguments
}
}
}
}
res.Body.Close()
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf(
"completions stream: read error: %w", err,
)
}
// Build messages in index order.
indices := make([]int, 0, len(accums))
for idx := range accums {
indices = append(indices, idx)
}
slices.Sort(indices)
msgs := make([]Message, 0, len(accums))
for _, idx := range indices {
acc := accums[idx]
// Content and refusal are mutually exclusive in the API.
var contentParts []ContentPart
if refusalStr := acc.refusal.String(); refusalStr != "" {
contentParts = []ContentPart{{
Type: ContentTypeRefusal,
Refusal: refusalStr,
}}
} else {
contentParts = []ContentPart{{
Type: ContentTypeText,
Text: acc.content.String(),
}}
}
message := Message{
Role: acc.role,
ContentParts: contentParts,
Refusal: acc.refusal.String(),
ReasoningContent: acc.reasoning.String(),
ToolCalls: acc.toolCalls,
}
events <- StreamEvent{
Type: streamEventDone,
Message: message,
}
msgs = append(msgs, message)
}
return msgs, nil
}

View File

@@ -0,0 +1,92 @@
package llm
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
)
// ListModels returns available models from the LLM server.
func (c *Client) ListModels(ctx context.Context) ([]Model, error) {
ctx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
req, err := http.NewRequestWithContext(
ctx, http.MethodGet, c.baseURL+"/models", nil,
)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
if c.key != "" {
req.Header.Set("Authorization", "Bearer "+c.key)
}
req.Header.Set("Content-Type", "application/json")
res, err := c.httpc.Do(req)
if err != nil {
return nil, fmt.Errorf("list models: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
body, err := io.ReadAll(res.Body)
if err != nil {
c.log.ErrorContext(
ctx,
"list models: failed to read error response body",
slog.Any("error", err),
)
return nil, fmt.Errorf(
"list models: status code %d", res.StatusCode,
)
}
var apiError struct {
Error *struct {
Type string `json:"type"`
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(
body, &apiError,
); err == nil && apiError.Error != nil {
c.log.ErrorContext(
ctx,
"list models: request error",
slog.Int("status", res.StatusCode),
slog.String("code", apiError.Error.Code),
slog.String(
"message", apiError.Error.Message,
),
slog.String("type", apiError.Error.Type),
)
return nil, fmt.Errorf(
"list models: %s: %s",
apiError.Error.Code,
apiError.Error.Message,
)
}
c.log.ErrorContext(
ctx,
"list models: API error",
slog.Int("status", res.StatusCode),
slog.String("body", string(body)),
)
return nil, fmt.Errorf(
"list models: %d: %s",
res.StatusCode, string(body),
)
}
var body ModelsResponse
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
return nil, fmt.Errorf(
"list models: decode response: %w", err,
)
}
return body.Data, nil
}

View File

@@ -0,0 +1,61 @@
package llm
import (
"context"
"fmt"
"log/slog"
"strings"
)
// callTools executes each tool call in the message and returns the
// resulting tool result messages.
func (c *Client) callTools(ctx context.Context, msg Message) (
results []Message, err error,
) {
for _, tc := range msg.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.ErrorContext(
ctx,
"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.InfoContext(
ctx,
"called tool",
slog.String("name", tc.Function.Name),
)
}
// Content cannot be empty.
if strings.TrimSpace(result) == "" {
result = `{"ok": true, "result": null}`
}
toolResult := Message{
Role: RoleTool,
ContentParts: []ContentPart{{
Type: ContentTypeText, Text: result,
}},
Name: tc.Function.Name,
ToolCallID: tc.ID,
}
results = append(results, toolResult)
}
return results, nil
}

View File

@@ -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"`
}

View File

@@ -0,0 +1,208 @@
package llm
import (
"context"
"log/slog"
"os"
"testing"
"code.chimeric.al/chimerical/odidere/internal/config"
)
// testURL returns the LLM endpoint for integration tests.
// Set ODIDERE_LLM_TEST_URL to enable; tests skip if empty.
func testURL(t *testing.T) string {
t.Helper()
url := os.Getenv("ODIDERE_LLM_TEST_URL")
if url == "" {
t.Skip("ODIDERE_LLM_TEST_URL not set")
}
return url
}
// testKey returns the API key for integration tests.
// Reads ODIDERE_LLM_TEST_KEY; empty string is valid (no auth).
func testKey(t *testing.T) string {
t.Helper()
return os.Getenv("ODIDERE_LLM_TEST_KEY")
}
// testModel returns the model ID for integration tests.
// Reads ODIDERE_LLM_TEST_MODEL; defaults to "test" if empty.
func testModel(t *testing.T) string {
t.Helper()
model := os.Getenv("ODIDERE_LLM_TEST_MODEL")
if model == "" {
return "test"
}
return model
}
func TestIntegration_ListModels(t *testing.T) {
_ = testURL(t)
cfg := Config{
URL: testURL(t),
Key: testKey(t),
Timeout: "30s",
}
client, err := NewClient(cfg, nil, slog.Default())
if err != nil {
t.Fatalf("NewClient: %v", err)
}
models, err := client.ListModels(context.Background())
if err != nil {
t.Fatalf("ListModels: %v", err)
}
if len(models) == 0 {
t.Error("expected at least one model")
}
for _, m := range models {
if m.ID == "" {
t.Error("model has empty ID")
}
}
}
func TestIntegration_Completions(t *testing.T) {
_ = testURL(t)
cfg := Config{
URL: testURL(t),
Key: testKey(t),
Timeout: "60s",
}
client, err := NewClient(cfg, nil, slog.Default())
if err != nil {
t.Fatalf("NewClient: %v", err)
}
msgs, err := client.Completions(
context.Background(),
testModel(t),
"",
0,
[]Message{{
Role: RoleUser,
ContentParts: []ContentPart{{
Type: "text",
Text: "Say hello in three words.",
}},
}},
)
if err != nil {
t.Fatalf("Completions: %v", err)
}
if len(msgs) == 0 {
t.Fatal("expected at least one message")
}
text := msgs[len(msgs)-1].Text()
if text == "" {
t.Error("final message has no text")
}
}
func TestIntegration_CompletionsStream(t *testing.T) {
_ = testURL(t)
cfg := Config{
URL: testURL(t),
Key: testKey(t),
Timeout: "60s",
}
client, err := NewClient(cfg, nil, slog.Default())
if err != nil {
t.Fatalf("NewClient: %v", err)
}
events := make(chan StreamEvent)
errCh := make(chan error, 1)
go func() {
errCh <- client.CompletionsStream(
context.Background(),
testModel(t),
"",
0,
[]Message{{
Role: RoleUser,
ContentParts: []ContentPart{{
Type: "text",
Text: "Say hello in three words.",
}},
}},
events,
)
}()
var messages []Message
for evt := range events {
messages = append(messages, evt.Message)
}
if err := <-errCh; err != nil {
t.Fatalf("CompletionsStream: %v", err)
}
if len(messages) == 0 {
t.Fatal("expected at least one message")
}
text := messages[len(messages)-1].Text()
if text == "" {
t.Error("final message has no text")
}
}
func TestIntegration_ToolCalling(t *testing.T) {
_ = testURL(t)
// Build a minimal tool registry with a single echo tool.
reg, err := NewRegistry([]config.ToolConfig{
{
Name: "echo",
Description: "Echo back the input string",
Command: "echo",
Arguments: []string{"{{.input}}"},
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"input": map[string]any{"type": "string"},
},
"required": []string{"input"},
},
},
})
if err != nil {
t.Fatalf("NewRegistry: %v", err)
}
cfg := Config{
URL: testURL(t),
Key: testKey(t),
Timeout: "60s",
}
client, err := NewClient(cfg, reg, slog.Default())
if err != nil {
t.Fatalf("NewClient: %v", err)
}
msgs, err := client.Completions(
context.Background(),
testModel(t),
"",
10,
[]Message{{
Role: RoleUser,
ContentParts: []ContentPart{{
Type: "text",
Text: "Use the echo tool to say 'hello'",
}},
}},
)
if err != nil {
t.Fatalf("Completions: %v", err)
}
if len(msgs) == 0 {
t.Fatal("expected at least one message")
}
// The final message should be a non-tool-call assistant message.
final := msgs[len(msgs)-1]
if len(final.ToolCalls) > 0 {
t.Error("final message still has tool calls")
}
}

224
internal/llm/tool.go Normal file
View File

@@ -0,0 +1,224 @@
package llm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os/exec"
"text/template"
"time"
"code.chimeric.al/chimerical/odidere/internal/config"
)
// fn provides template functions available to argument templates.
var fn = template.FuncMap{
"json": func(v any) string {
b, _ := json.Marshal(v)
return string(b)
},
}
// Tool represents an external tool that can be invoked by LLMs.
// Tools are executed as subprocesses with templated arguments,
// and described to the LLM via their JSON Schema parameters.
type Tool struct {
// Name uniquely identifies the tool within a registry.
Name string
// Description explains the tool's purpose for the LLM.
Description string
// Command is the executable path or name.
Command string
// Arguments are Go templates expanded with LLM-provided parameters.
Arguments []string
// Parameters is a JSON Schema describing expected input from the LLM.
Parameters map[string]any
// timeout is the parsed execution duration limit.
timeout time.Duration
}
// NewTool creates a Tool from a ToolConfig.
func NewTool(cfg config.ToolConfig) (*Tool, error) {
var timeout time.Duration
if cfg.Timeout != "" {
var err error
timeout, err = time.ParseDuration(cfg.Timeout)
if err != nil {
return nil, fmt.Errorf(
"invalid timeout %q: %w", cfg.Timeout, err,
)
}
}
return &Tool{
Name: cfg.Name,
Description: cfg.Description,
Command: cfg.Command,
Arguments: cfg.Arguments,
Parameters: cfg.Parameters,
timeout: timeout,
}, nil
}
// ParseArguments expands argument templates with the provided JSON data.
// The args parameter should be a JSON object string; empty string or "{}"
// results in an empty data map. Templates producing empty strings are
// filtered from the result, allowing conditional arguments.
func (t *Tool) ParseArguments(args string) ([]string, error) {
var data = map[string]any{}
if args != "" && args != "{}" {
if err := json.Unmarshal([]byte(args), &data); err != nil {
return nil, fmt.Errorf(
"invalid arguments JSON: %w", err,
)
}
}
var result []string
for _, v := range t.Arguments {
tmpl, err := template.New("").Funcs(fn).Parse(v)
if err != nil {
return nil, fmt.Errorf(
"invalid template %q: %w", v, err,
)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return nil, fmt.Errorf(
"execute template %q: %w", v, err,
)
}
// Filter out empty strings (unused conditional arguments).
if s := buf.String(); s != "" {
result = append(result, s)
}
}
return result, nil
}
// ToAPI converts the tool definition into the API format suitable for
// inclusion in a ChatRequest.
func (t *Tool) ToAPI() APITool {
return APITool{
Type: "function",
Function: &FunctionDef{
Name: t.Name,
Description: t.Description,
Parameters: t.Parameters,
},
}
}
// Registry holds tools indexed by name and handles their execution.
// It validates all tool definitions at construction time to fail fast on
// configuration errors.
type Registry struct {
tools map[string]*Tool
}
// NewRegistry creates a registry from the provided tool definitions.
// Returns an error if any tool fails validation or if duplicate names exist.
func NewRegistry(tools []config.ToolConfig) (*Registry, error) {
var r = &Registry{
tools: make(map[string]*Tool),
}
for _, tc := range tools {
t, err := NewTool(tc)
if err != nil {
return nil, fmt.Errorf(
"invalid tool %q: %w", tc.Name, err,
)
}
if _, exists := r.tools[t.Name]; exists {
return nil, fmt.Errorf(
"duplicate tool name: %s", t.Name,
)
}
r.tools[t.Name] = t
}
return r, nil
}
// Get returns a tool by name and a boolean indicating if it was found.
func (r *Registry) Get(name string) (*Tool, bool) {
tool, ok := r.tools[name]
return tool, ok
}
// List returns all registered tool names in arbitrary order.
func (r *Registry) List() []string {
names := make([]string, 0, len(r.tools))
for name := range r.tools {
names = append(names, name)
}
return names
}
// ToAPI converts all registered tools into API format suitable for
// inclusion in a ChatRequest.
func (r *Registry) ToAPI() []APITool {
if r == nil {
return nil
}
tools := make([]APITool, 0, len(r.tools))
for _, t := range r.tools {
tools = append(tools, t.ToAPI())
}
return tools
}
// Execute runs a tool by name with the provided JSON arguments.
// It expands argument templates, executes the command as a subprocess, and
// returns stdout on success. The context can be used for cancellation;
// tool-specific timeouts are applied on top of any context deadline.
func (r *Registry) Execute(
ctx context.Context, name string, args string,
) (string, error) {
tool, ok := r.tools[name]
if !ok {
return "", fmt.Errorf("unknown tool: %s", name)
}
// Evaluate argument templates.
cmdArgs, err := tool.ParseArguments(args)
if err != nil {
return "", fmt.Errorf("parse arguments: %w", err)
}
// If defined, use the timeout.
if tool.timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, tool.timeout)
defer cancel()
}
// Setup and run the command.
var (
stdout, stderr bytes.Buffer
cmd = exec.CommandContext(
ctx, tool.Command, cmdArgs...,
)
)
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded && tool.timeout > 0 {
return "", fmt.Errorf(
"tool %s timed out after %v",
name, tool.timeout,
)
}
return "", fmt.Errorf(
"tool %s: %w\nstderr: %s",
name, err, stderr.String(),
)
}
return stdout.String(), nil
}

203
internal/llm/tool_test.go Normal file
View File

@@ -0,0 +1,203 @@
package llm
import (
"testing"
"time"
"code.chimeric.al/chimerical/odidere/internal/config"
)
func TestTool_ParseArguments(t *testing.T) {
tool := &Tool{
Arguments: []string{"--name {{.name}}", "{{if .flag}}--enabled{{end}}"},
}
tests := []struct {
name string
args string
expected []string
}{
{
name: "basic",
args: `{"name": "test"}`,
expected: []string{"--name test"},
},
{
name: "with flag",
args: `{"name": "test", "flag": true}`,
expected: []string{"--name test", "--enabled"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tool.ParseArguments(tt.args)
if err != nil {
t.Fatalf("ParseArguments failed: %v", err)
}
if len(got) != len(tt.expected) {
t.Errorf("expected len %d, got %d", len(tt.expected), len(got))
}
for i := range got {
if got[i] != tt.expected[i] {
t.Errorf("expected %q, got %q", tt.expected[i], got[i])
}
}
})
}
}
func TestNewTool(t *testing.T) {
tests := []struct {
name string
cfg config.ToolConfig
wantErr bool
}{
{
name: "valid",
cfg: config.ToolConfig{
Name: "test",
Description: "desc",
Command: "ls",
Timeout: "10s",
},
wantErr: false,
},
{
name: "invalid timeout",
cfg: config.ToolConfig{
Name: "test",
Description: "desc",
Command: "ls",
Timeout: "invalid",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tool, err := NewTool(tt.cfg)
if (err != nil) != tt.wantErr {
t.Errorf("NewTool() error = %v, wantErr %v", err, tt.wantErr)
}
if err == nil {
if tool.Name != tt.cfg.Name {
t.Errorf("expected name %s, got %s", tt.cfg.Name, tool.Name)
}
if tt.cfg.Timeout == "10s" && tool.timeout != 10*time.Second {
t.Errorf("expected timeout 10s, got %v", tool.timeout)
}
}
})
}
}
func TestRegistry_NewRegistry(t *testing.T) {
tests := []struct {
name string
tools []config.ToolConfig
wantErr bool
}{
{
name: "valid",
tools: []config.ToolConfig{
{Name: "t1", Description: "d1", Command: "c1"},
{Name: "t2", Description: "d2", Command: "c2"},
},
wantErr: false,
},
{
name: "duplicate",
tools: []config.ToolConfig{
{Name: "t1", Description: "d1", Command: "c1"},
{Name: "t1", Description: "d2", Command: "c2"},
},
wantErr: true,
},
{
name: "invalid tool",
tools: []config.ToolConfig{
{Name: "t1", Description: "d1", Command: "c1", Timeout: "invalid"},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := NewRegistry(tt.tools)
if (err != nil) != tt.wantErr {
t.Errorf("NewRegistry() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestRegistry_ToAPI_Nil(t *testing.T) {
var reg *Registry
got := reg.ToAPI()
if got != nil {
t.Errorf("Registry.ToAPI(nil) = %v, want nil", got)
}
}
func TestRegistry_ToAPI_Empty(t *testing.T) {
reg, err := NewRegistry(nil)
if err != nil {
t.Fatalf("NewRegistry(nil) error = %v", err)
}
got := reg.ToAPI()
if len(got) != 0 {
t.Errorf("Registry.ToAPI(empty) len = %d, want 0", len(got))
}
}
func TestRegistry_ToAPI_Single(t *testing.T) {
reg, err := NewRegistry([]config.ToolConfig{
{
Name: "weather",
Description: "Get the weather",
Parameters: map[string]any{"type": "object"},
},
})
if err != nil {
t.Fatalf("NewRegistry error = %v", err)
}
got := reg.ToAPI()
if len(got) != 1 {
t.Fatalf("len = %d, want 1", len(got))
}
if got[0].Type != "function" {
t.Errorf("type = %q, want %q", got[0].Type, "function")
}
if got[0].Function.Name != "weather" {
t.Errorf("name = %q, want %q", got[0].Function.Name, "weather")
}
if got[0].Function.Description != "Get the weather" {
t.Errorf("description = %q, want %q", got[0].Function.Description, "Get the weather")
}
}
func TestTool_ToAPI(t *testing.T) {
tool := &Tool{
Name: "test",
Description: "A test tool",
Parameters: map[string]any{"type": "object"},
Command: "echo",
Arguments: []string{"hello"},
}
api := tool.ToAPI()
if api.Type != "function" {
t.Errorf("type = %q, want %q", api.Type, "function")
}
if api.Function.Name != "test" {
t.Errorf("name = %q, want %q", api.Function.Name, "test")
}
if api.Function.Description != "A test tool" {
t.Errorf("description = %q, want %q", api.Function.Description, "A test tool")
}
if api.Function.Parameters == nil {
t.Error("parameters is nil")
}
}