Refactor llm package

This commit is contained in:
dwrz
2026-09-20 12:49:42 +00:00
parent eba6c2a028
commit 7224fea99f
11 changed files with 1176 additions and 1627 deletions
+48 -38
View File
@@ -7,20 +7,34 @@ import (
"time"
)
// Config holds the configuration for an LLM client.
const (
// chatCompletionsPath is the endpoint for chat completion requests.
chatCompletionsPath = "/chat/completions"
// chatTemplateEnableThinking is the chat-template argument that selects
// whether a reasoning model emits reasoning tokens.
chatTemplateEnableThinking = "enable_thinking"
// defaultTimeout limits requests when Config.Timeout is empty.
defaultTimeout = 5 * time.Minute
// sseDone is the sentinel that terminates a chat completion stream.
sseDone = "[DONE]"
)
// Config configures a Client.
type Config struct {
// Key is the API key for authentication.
// Key is sent as a bearer token. An empty key disables 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.
// Provider is returned unchanged by Client.Provider. It is not sent to the
// inference endpoint.
Provider string `yaml:"provider"`
// Timeout limits each request, including reading a streaming response. It
// uses Go duration syntax and defaults to five minutes when empty.
Timeout string `yaml:"timeout"`
// URL is the base URL of the OpenAI-compatible API endpoint.
// URL is the endpoint root. Client appends /chat/completions or /models.
URL string `yaml:"url"`
}
// Validate checks that required configuration values are present and valid.
// Validate reports whether Timeout is a Go duration, when set, and URL is
// non-empty. It does not contact the endpoint.
func (cfg Config) Validate() error {
if cfg.Timeout != "" {
if _, err := time.ParseDuration(cfg.Timeout); err != nil {
@@ -33,30 +47,27 @@ func (cfg Config) Validate() error {
return nil
}
// Client wraps an OpenAI-compatible client with tool execution support.
// Client is an OpenAI-compatible inference client.
type Client struct {
baseURL string
httpc *http.Client
key string
log *slog.Logger
registry *Registry
systemMessage string
timeout time.Duration
tools []APITool
baseURL string
httpc *http.Client
key string
log *slog.Logger
provider string
timeout time.Duration
}
// 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) {
// NewClient creates a client from cfg. If cfg.Timeout is empty, requests have a
// five-minute timeout. log must be non-nil.
func NewClient(cfg Config, log *slog.Logger) (*Client, error) {
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
if log == nil {
return nil, fmt.Errorf("missing logger")
}
timeout := 5 * time.Minute
timeout := defaultTimeout
if cfg.Timeout != "" {
d, err := time.ParseDuration(cfg.Timeout)
if err != nil {
@@ -65,18 +76,17 @@ func NewClient(
timeout = d
}
llm := &Client{
baseURL: cfg.URL,
key: cfg.Key,
log: log,
systemMessage: cfg.SystemMessage,
registry: registry,
httpc: &http.Client{},
timeout: timeout,
}
return &Client{
baseURL: cfg.URL,
key: cfg.Key,
log: log,
provider: cfg.Provider,
httpc: &http.Client{},
timeout: timeout,
}, nil
}
// Convert tools from registry.
llm.tools = registry.ToAPI()
return llm, nil
// Provider returns the application-defined provider name supplied in Config.
func (c *Client) Provider() string {
return c.provider
}
+280 -352
View File
@@ -9,119 +9,70 @@ import (
"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")
// CompletionsResult bundles the messages generated during a completions
// request along with usage and timings from the final API response.
type CompletionsResult struct {
Messages []Message
Usage *Usage
Timings *Timings
}
// CompletionsParams holds parameters for Completions and CompletionsStream.
// CompletionsParams contains the inputs to a chat completion.
type CompletionsParams struct {
Model string
SystemMessage string
MaxIterations int
Messages []Message
// Messages is the conversation presented to the model, in order.
Messages []Message
// Model identifies the model according to the configured endpoint.
Model string
// ThinkingBudgetTokens is sent as the endpoint's reasoning-token budget.
// Nil omits both the budget and enable_thinking; zero sets enable_thinking
// to false; any other value sets it to true.
ThinkingBudgetTokens *int
// Tools describes the functions available for the model to call.
Tools []APITool
}
// 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, along with usage and timings from the final response.
// CompletionResult contains one generated Message and its response metadata.
type CompletionResult struct {
// FinishReason is the endpoint's reason for ending generation.
FinishReason string
// Message is the generated message.
Message Message
// Timings contains llama.cpp performance data, when reported.
Timings *Timings
// Usage contains token counts, when reported.
Usage *Usage
}
// Completions requests a non-streaming chat completion and returns the first
// choice. It returns an error if the response contains no choices.
func (c *Client) Completions(
ctx context.Context, p CompletionsParams,
) (*CompletionsResult, error) {
) (*CompletionResult, error) {
ctx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
model, systemMessage, maxIterations, messages, thinkingBudgetTokens :=
p.Model, p.SystemMessage, p.MaxIterations, p.Messages, p.ThinkingBudgetTokens
// Use request system message if provided, config default otherwise.
smsg := c.systemMessage
if systemMessage != "" {
smsg = systemMessage
cr, err := c.completions(ctx, p)
if err != nil {
return nil, err
}
// 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.
var finalUsage *Usage
var finalTimings *Timings
for i := 0; maxIterations == 0 || i < maxIterations; i++ {
cr, err := c.completions(ctx, model, thinkingBudgetTokens, messages)
if err != nil {
return nil, fmt.Errorf("completions: %w", err)
}
// Capture usage and timings from this iteration.
if cr.Usage != nil {
finalUsage = cr.Usage
}
if cr.Timings != nil {
finalTimings = cr.Timings
}
// Use first choice for tool call loop.
message := &cr.Choices[0].Message
messages = append(messages, *message)
if len(message.ToolCalls) == 0 {
return &CompletionsResult{
Messages: messages[start:],
Usage: finalUsage,
Timings: finalTimings,
}, nil
}
toolResults := c.callTools(ctx, *message)
messages = append(messages, toolResults...)
}
return &CompletionsResult{
Messages: messages[start:],
Usage: finalUsage,
Timings: finalTimings,
}, ErrMaxIterations
choice := cr.Choices[0]
return &CompletionResult{
Message: choice.Message,
FinishReason: choice.FinishReason,
Usage: cr.Usage,
Timings: cr.Timings,
}, nil
}
// completions sends a single non-streaming request and returns the
// full API response.
func (c *Client) completions(
ctx context.Context, model string, thinkingBudgetTokens *int, messages []Message,
ctx context.Context, p CompletionsParams,
) (*ChatResponse, error) {
kwargs := map[string]any{}
if thinkingBudgetTokens != nil {
kwargs["enable_thinking"] = *thinkingBudgetTokens != 0
if p.ThinkingBudgetTokens != nil {
kwargs[chatTemplateEnableThinking] = *p.ThinkingBudgetTokens != 0
}
body, err := json.Marshal(ChatRequest{
Model: model,
Messages: messages,
Tools: c.tools,
ThinkingBudgetTokens: thinkingBudgetTokens,
Model: p.Model,
Messages: p.Messages,
Tools: p.Tools,
ThinkingBudgetTokens: p.ThinkingBudgetTokens,
ChatTemplateKWARGS: kwargs,
ParallelToolCalls: new(true),
})
@@ -133,7 +84,7 @@ func (c *Client) completions(
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
c.baseURL+"/chat/completions",
c.baseURL+chatCompletionsPath,
bytes.NewReader(body),
)
if err != nil {
@@ -163,11 +114,7 @@ func (c *Client) completions(
)
}
var apiError struct {
Error *struct {
Type string `json:"type"`
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
Error *APIError `json:"error"`
}
if err := json.Unmarshal(
body, &apiError,
@@ -182,11 +129,7 @@ func (c *Client) completions(
),
slog.String("type", apiError.Error.Type),
)
return nil, fmt.Errorf(
"completions: %s: %s",
apiError.Error.Code,
apiError.Error.Message,
)
return nil, fmt.Errorf("completions: %w", apiError.Error)
}
c.log.ErrorContext(
ctx,
@@ -212,131 +155,140 @@ func (c *Client) completions(
return cr, nil
}
// StreamEvent wraps a message or delta produced during streaming.
type StreamEvent struct {
// Type identifies the event kind: delta, reasoning_delta, done, or message.
Type string
// Delta is a text fragment from the assistant response.
Delta string
// ReasoningDelta is a reasoning text fragment from the assistant response.
ReasoningDelta string
// Message is the complete chat completion message.
Message Message
// Usage is token usage info from the final chunk, sent on the done event.
Usage *Usage
// Timings is llama.cpp timing info from the final chunk, sent on the done event.
Timings *Timings
// StreamAccumulator reconstructs a Message from the ordered deltas for one
// streaming choice. The zero value is ready for use.
type StreamAccumulator struct {
// Content contains the accumulated message text.
Content strings.Builder
// Reasoning combines fragments received under either supported reasoning
// field name.
Reasoning strings.Builder
// Refusal contains the accumulated refusal text.
Refusal strings.Builder
// Role is the latest non-empty role received.
Role Role
// ToolCalls contains calls assembled by tool-call index.
ToolCalls []ToolCall
}
const (
streamEventDelta = "delta"
streamEventReasoningDelta = "reasoning_delta"
streamEventDone = "done"
streamEventMessage = "message"
)
// Add incorporates the next delta. Content, refusal, reasoning, and tool-call
// fragments are appended in arrival order. Tool-call fragments are grouped by
// Index; a missing index denotes the first call. A non-empty Role replaces the
// previously accumulated role.
func (acc *StreamAccumulator) Add(delta Delta) {
if delta.Role != "" {
acc.Role = delta.Role
}
acc.Content.WriteString(delta.Content)
acc.Refusal.WriteString(delta.Refusal)
acc.Reasoning.WriteString(delta.ReasoningContent)
acc.Reasoning.WriteString(delta.Reasoning)
// 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.
for _, tc := range delta.ToolCalls {
idx := 0
if tc.Index != nil {
idx = *tc.Index
}
for len(acc.ToolCalls) <= idx {
acc.ToolCalls = append(
acc.ToolCalls,
ToolCall{Type: ToolTypeFunction},
)
}
if tc.ID != "" {
acc.ToolCalls[idx].ID = tc.ID
}
if tc.Type != "" {
acc.ToolCalls[idx].Type = tc.Type
}
acc.ToolCalls[idx].Function.Name += tc.Function.Name
acc.ToolCalls[idx].Function.Arguments += tc.Function.Arguments
}
}
// Message returns the accumulated state without resetting the accumulator. If
// the stream contains a refusal, the returned content consists of that refusal
// rather than the accumulated text.
func (acc *StreamAccumulator) Message() Message {
var contentParts []ContentPart
if refusal := acc.Refusal.String(); refusal != "" {
contentParts = []ContentPart{{
Type: ContentTypeRefusal,
Refusal: refusal,
}}
} else {
contentParts = []ContentPart{{
Type: ContentTypeText,
Text: acc.Content.String(),
}}
}
return Message{
ContentParts: contentParts,
ReasoningContent: acc.Reasoning.String(),
Refusal: acc.Refusal.String(),
Role: acc.Role,
ToolCalls: acc.ToolCalls,
}
}
// CompletionStream is a caller-driven chat-completion stream. A stream owns
// its response body and timeout context until it terminates or is closed.
type CompletionStream struct {
ctx context.Context
reader *bufio.Reader
body io.ReadCloser
cancel context.CancelFunc
acc StreamAccumulator
current StreamChunk
finishReason string
usage *Usage
timings *Timings
err error
cleanupErr error
terminated bool
resourcesDone bool
}
// CompletionsStream opens a streaming chat completion. Request setup,
// transport, and non-success response errors are returned before a stream is
// created.
func (c *Client) CompletionsStream(
ctx context.Context, p CompletionsParams, events chan<- StreamEvent,
) error {
defer close(events)
ctx context.Context,
p CompletionsParams,
) (*CompletionStream, error) {
ctx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
model, systemMessage, maxIterations, messages, thinkingBudgetTokens :=
p.Model, p.SystemMessage, p.MaxIterations, p.Messages, p.ThinkingBudgetTokens
// 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, thinkingBudgetTokens, 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 := c.callTools(ctx, message)
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. Usage and timings are included
// on the done event for the last message.
func (c *Client) completionsStream(ctx context.Context, messages []Message, model string, thinkingBudgetTokens *int, events chan<- StreamEvent) ([]Message, error) {
kwargs := map[string]any{}
if thinkingBudgetTokens != nil {
kwargs["enable_thinking"] = *thinkingBudgetTokens != 0
if p.ThinkingBudgetTokens != nil {
kwargs[chatTemplateEnableThinking] = *p.ThinkingBudgetTokens != 0
}
body, err := json.Marshal(ChatRequest{
Model: model,
Messages: messages,
Stream: true,
Tools: c.tools,
ThinkingBudgetTokens: thinkingBudgetTokens,
ChatTemplateKWARGS: kwargs,
Messages: p.Messages,
Model: p.Model,
ParallelToolCalls: new(true),
Stream: true,
StreamOptions: &StreamOptions{IncludeUsage: true},
ThinkingBudgetTokens: p.ThinkingBudgetTokens,
Tools: p.Tools,
})
if err != nil {
cancel()
return nil, fmt.Errorf(
"completions stream: marshal request body: %w", err,
)
}
req, err := http.NewRequestWithContext(
ctx, http.MethodPost,
c.baseURL+"/chat/completions",
ctx,
http.MethodPost,
c.baseURL+chatCompletionsPath,
bytes.NewReader(body),
)
if err != nil {
cancel()
return nil, fmt.Errorf(
"completions stream: create request: %w", err,
)
@@ -349,12 +301,14 @@ func (c *Client) completionsStream(ctx context.Context, messages []Message, mode
res, err := c.httpc.Do(req)
if err != nil {
cancel()
return nil, fmt.Errorf("completions stream: %w", err)
}
if res.StatusCode != http.StatusOK {
defer cancel()
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
res.Body.Close()
if err != nil {
c.log.ErrorContext(
ctx,
@@ -367,11 +321,7 @@ func (c *Client) completionsStream(ctx context.Context, messages []Message, mode
)
}
var apiError struct {
Error *struct {
Type string `json:"type"`
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
Error *APIError `json:"error"`
}
if err := json.Unmarshal(
body, &apiError,
@@ -381,15 +331,11 @@ func (c *Client) completionsStream(ctx context.Context, messages []Message, mode
"completions stream: request error",
slog.Int("status", res.StatusCode),
slog.String("code", apiError.Error.Code),
slog.String(
"message", apiError.Error.Message,
),
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,
"completions stream: %w", apiError.Error,
)
}
c.log.ErrorContext(
@@ -400,161 +346,143 @@ func (c *Client) completionsStream(ctx context.Context, messages []Message, mode
)
return nil, fmt.Errorf(
"completions stream: %d: %s",
res.StatusCode, string(body),
res.StatusCode,
string(body),
)
}
// Accumulate the streamed response per choice index.
accums := make(map[int]*streamAccum)
var usage *Usage
var timings *Timings
return &CompletionStream{
ctx: ctx,
reader: bufio.NewReader(res.Body),
body: res.Body,
cancel: cancel,
}, nil
}
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
// Next advances to the next JSON data event. It returns false after [DONE], an
// error, or Close. On an error, Err reports the cause and Result remains
// available with all chunks accumulated before the error.
func (s *CompletionStream) Next() bool {
if s.terminated {
return false
}
var data []string
for {
line, err := s.reader.ReadString('\n')
if err != nil && err != io.EOF {
s.fail(fmt.Errorf("completions stream: read response: %w", err))
return false
}
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,
)
events <- StreamEvent{
Type: streamEventReasoningDelta,
ReasoningDelta: delta.ReasoningContent,
}
}
if delta.Reasoning != "" {
acc.reasoning.WriteString(delta.Reasoning)
events <- StreamEvent{
Type: streamEventReasoningDelta,
ReasoningDelta: delta.Reasoning,
}
}
// 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 len(line) > 0 {
line = strings.TrimSuffix(line, "\n")
line = strings.TrimSuffix(line, "\r")
if line == "" {
if len(data) > 0 {
break
}
if tc.ID != "" {
acc.toolCalls[idx].ID = tc.ID
} else if line[0] != ':' {
field, value, found := strings.Cut(line, ":")
if !found {
field = line
value = ""
}
if tc.Function.Name != "" {
acc.toolCalls[idx].Function.Name +=
tc.Function.Name
value = strings.TrimPrefix(value, " ")
if field == "data" {
data = append(data, value)
}
if tc.Function.Arguments != "" {
acc.toolCalls[idx].Function.Arguments +=
tc.Function.Arguments
}
}
}
// Capture usage from the final chunk (has null choices).
if chunk.Usage != nil {
usage = chunk.Usage
}
if chunk.Timings != nil {
timings = chunk.Timings
if err == io.EOF {
s.fail(io.ErrUnexpectedEOF)
return false
}
}
res.Body.Close()
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf(
"completions stream: read error: %w", err,
)
payload := strings.Join(data, "\n")
if payload == sseDone {
s.terminated = true
s.release()
return false
}
// Build messages in index order.
indices := make([]int, 0, len(accums))
for idx := range accums {
indices = append(indices, idx)
var chunk StreamChunk
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
s.fail(fmt.Errorf("completions stream: decode chunk: %w", err))
return false
}
for _, choice := range chunk.Choices {
if choice.Index != 0 {
s.fail(fmt.Errorf(
"completions stream: unexpected choice index %d",
choice.Index,
))
return false
}
if choice.FinishReason != "" {
s.finishReason = choice.FinishReason
}
s.acc.Add(choice.Delta)
}
if chunk.Usage != nil {
s.usage = chunk.Usage
}
slices.Sort(indices)
if chunk.Timings != nil {
s.timings = chunk.Timings
}
s.current = chunk
return true
}
msgs := make([]Message, 0, len(accums))
for i, idx := range indices {
acc := accums[idx]
// Current returns the most recently decoded chunk. The value is replaced by
// the next successful call to Next.
func (s *CompletionStream) Current() StreamChunk {
return s.current
}
// 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(),
}}
}
// Result returns a snapshot of the state accumulated so far. Err reports
// whether the snapshot is a complete response or a partial result.
func (s *CompletionStream) Result() CompletionResult {
message := s.acc.Message()
message.ToolCalls = append([]ToolCall(nil), message.ToolCalls...)
return CompletionResult{
Message: message,
FinishReason: s.finishReason,
Usage: s.usage,
Timings: s.timings,
}
}
// Err returns the stream failure, if any. Closing a stream does not create or
// replace a stream failure.
func (s *CompletionStream) Err() error {
return s.err
}
message := Message{
Role: acc.role,
ContentParts: contentParts,
Refusal: acc.refusal.String(),
ReasoningContent: acc.reasoning.String(),
ToolCalls: acc.toolCalls,
}
// Close releases the response body and timeout context. It is safe to call
// repeatedly and returns the first response-body cleanup error.
func (s *CompletionStream) Close() error {
s.terminated = true
s.release()
return s.cleanupErr
}
// Attach usage and timings to the done event of the last message.
var evUsage *Usage
var evTimings *Timings
if i == len(indices)-1 {
evUsage = usage
evTimings = timings
}
events <- StreamEvent{
Type: streamEventDone,
Message: message,
Usage: evUsage,
Timings: evTimings,
}
msgs = append(msgs, message)
func (s *CompletionStream) fail(err error) {
if ctxErr := s.ctx.Err(); ctxErr != nil {
err = ctxErr
}
s.err = err
s.terminated = true
s.release()
}
return msgs, nil
func (s *CompletionStream) release() {
if s.resourcesDone {
return
}
s.resourcesDone = true
if err := s.body.Close(); err != nil {
s.cleanupErr = err
}
s.cancel()
}
+222 -167
View File
@@ -2,96 +2,159 @@ package llm
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestCompletionsStreamStreamsReasoningDeltas(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/chat/completions" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
func newCompletionTestClient(t *testing.T, handler http.HandlerFunc) *Client {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
client, err := NewClient(Config{Provider: "test", URL: srv.URL}, slog.Default())
if err != nil {
t.Fatalf("NewClient: %v", err)
}
return client
}
func openCompletionTestStream(t *testing.T, client *Client) *CompletionStream {
t.Helper()
stream, err := client.CompletionsStream(
t.Context(), CompletionsParams{Model: "test-model"},
)
if err != nil {
t.Fatalf("CompletionsStream: %v", err)
}
t.Cleanup(func() { _ = stream.Close() })
return stream
}
func TestCompletionsStreamReconstructsReasoning(t *testing.T) {
client := newCompletionTestClient(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
for _, data := range []string{
`{"choices":[{"index":0,"delta":{"role":"assistant"}}]}`,
`{"choices":[{"index":0,"delta":{"reasoning_content":"thinking"}}]}`,
`{"choices":[{"index":0,"delta":{"reasoning_content":" more"}}]}`,
`{"choices":[{"index":0,"delta":{"content":"answer"}}]}`,
`{"choices":[{"index":0,"delta":{"content":"answer"},"finish_reason":"stop"}]}`,
} {
fmt.Fprintf(w, "data: %s\n\n", data)
}
fmt.Fprint(w, "data: [DONE]\n\n")
}))
defer srv.Close()
})
client, err := NewClient(Config{URL: srv.URL}, nil, nil)
stream := openCompletionTestStream(t, client)
if got := stream.Result(); got.Message.Text() != "" {
t.Fatalf("initial result text = %q, want empty", got.Message.Text())
}
var chunks []StreamChunk
for stream.Next() {
chunks = append(chunks, stream.Current())
}
if err := stream.Err(); err != nil {
t.Fatalf("stream.Err: %v", err)
}
if len(chunks) != 4 {
t.Fatalf("chunks = %d, want 4", len(chunks))
}
result := stream.Result()
if result.Message.ReasoningContent != "thinking more" {
t.Errorf("reasoning = %q", result.Message.ReasoningContent)
}
if result.Message.Text() != "answer" {
t.Errorf("text = %q", result.Message.Text())
}
if result.FinishReason != "stop" {
t.Errorf("finish reason = %q", result.FinishReason)
}
if stream.Next() {
t.Error("Next returned true after termination")
}
}
func TestCompletionsStreamReportsCancellationWithPartialResult(t *testing.T) {
firstSent := make(chan struct{})
client := newCompletionTestClient(t, func(w http.ResponseWriter, r *http.Request) {
flusher := w.(http.Flusher)
fmt.Fprint(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"first\"}}]}\n\n")
flusher.Flush()
close(firstSent)
<-r.Context().Done()
})
ctx, cancel := context.WithCancel(t.Context())
stream, err := client.CompletionsStream(ctx, CompletionsParams{Model: "test-model"})
if err != nil {
t.Fatalf("NewClient: %v", err)
}
events := make(chan StreamEvent)
errCh := make(chan error, 1)
go func() {
errCh <- client.CompletionsStream(
context.Background(), CompletionsParams{
Model: "test-model",
SystemMessage: "",
MaxIterations: 0,
Messages: []Message{{
Role: RoleUser,
ContentParts: []ContentPart{{
Type: ContentTypeText,
Text: "hello",
}},
}},
}, events,
)
}()
var got []StreamEvent
for evt := range events {
got = append(got, evt)
}
if err := <-errCh; err != nil {
t.Fatalf("CompletionsStream: %v", err)
}
defer stream.Close()
<-firstSent
if !stream.Next() {
t.Fatalf("first Next = false: %v", stream.Err())
}
cancel()
if stream.Next() {
t.Fatal("Next returned true after cancellation")
}
if !errors.Is(stream.Err(), context.Canceled) {
t.Fatalf("Err = %v, want context.Canceled", stream.Err())
}
result := stream.Result()
if got := result.Message.Text(); got != "first" {
t.Errorf("partial text = %q, want first", got)
}
}
wantTypes := []string{
streamEventReasoningDelta,
streamEventReasoningDelta,
streamEventDelta,
streamEventDone,
}
if len(got) != len(wantTypes) {
t.Fatalf("got %d events, want %d: %#v", len(got), len(wantTypes), got)
}
for i, want := range wantTypes {
if got[i].Type != want {
t.Fatalf("event %d type = %q, want %q", i, got[i].Type, want)
func TestCompletionsStreamReconstructsFragmentedParallelToolCalls(t *testing.T) {
client := newCompletionTestClient(t, func(w http.ResponseWriter, _ *http.Request) {
for _, data := range []string{
`{"choices":[{"index":0,"delta":{"role":"assistant"}}]}`,
`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_0","function":{"name":"ec"}},{"index":1,"id":"call_1","function":{"name":"pr"}}]}}]}`,
`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"name":"ho","arguments":"{\"value\":"}},{"index":1,"function":{"name":"intf","arguments":"{\"format\":"}}]}}]}`,
`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"\"%s\"}"}},{"index":0,"function":{"arguments":"\"one\"}"}}]},"finish_reason":"tool_calls"}]}`,
} {
fmt.Fprintf(w, "data: %s\n\n", data)
}
fmt.Fprint(w, "data: [DONE]\n\n")
})
stream := openCompletionTestStream(t, client)
var chunks []StreamChunk
for stream.Next() {
chunks = append(chunks, stream.Current())
}
if got[0].ReasoningDelta != "thinking" {
t.Errorf("first reasoning delta = %q", got[0].ReasoningDelta)
if err := stream.Err(); err != nil {
t.Fatalf("stream.Err: %v", err)
}
if got[1].ReasoningDelta != " more" {
t.Errorf("second reasoning delta = %q", got[1].ReasoningDelta)
if len(chunks) != 4 || len(chunks[1].Choices[0].Delta.ToolCalls) != 2 {
t.Fatalf("tool-call chunks not exposed unchanged: %#v", chunks)
}
if got[2].Delta != "answer" {
t.Errorf("content delta = %q", got[2].Delta)
result := stream.Result()
toolCalls := result.Message.ToolCalls
if len(toolCalls) != 2 {
t.Fatalf("tool calls = %d, want 2", len(toolCalls))
}
final := got[3].Message
if final.ReasoningContent != "thinking more" {
t.Errorf("final reasoning = %q", final.ReasoningContent)
if toolCalls[0].ID != "call_0" || toolCalls[0].Function.Name != "echo" ||
toolCalls[0].Function.Arguments != `{"value":"one"}` {
t.Errorf("tool call 0 = %#v", toolCalls[0])
}
if final.Text() != "answer" {
t.Errorf("final text = %q", final.Text())
if toolCalls[1].ID != "call_1" || toolCalls[1].Function.Name != "printf" ||
toolCalls[1].Function.Arguments != `{"format":"%s"}` {
t.Errorf("tool call 1 = %#v", toolCalls[1])
}
if result.FinishReason != "tool_calls" {
t.Errorf("finish reason = %q", result.FinishReason)
}
}
func TestCompletionsReturnsUsageAndTimings(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
client := newCompletionTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/chat/completions" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
@@ -122,135 +185,127 @@ func TestCompletionsReturnsUsageAndTimings(t *testing.T) {
"predicted_per_second": 50.0
}
}`)
}))
defer srv.Close()
})
client, err := NewClient(Config{URL: srv.URL}, nil, nil)
if err != nil {
t.Fatalf("NewClient: %v", err)
}
res, err := client.Completions(
context.Background(), CompletionsParams{
Model: "test-model",
SystemMessage: "",
MaxIterations: 0,
Messages: []Message{{
Role: RoleUser,
ContentParts: []ContentPart{{
Type: ContentTypeText,
Text: "hi",
}},
}},
},
)
res, err := client.Completions(context.Background(), CompletionsParams{Model: "test-model"})
if err != nil {
t.Fatalf("Completions: %v", err)
}
if len(res.Messages) != 1 {
t.Fatalf("got %d messages, want 1", len(res.Messages))
if res.Message.Text() != "hello" || res.Usage == nil || res.Timings == nil {
t.Fatalf("unexpected result: %#v", res)
}
if res.Usage == nil {
t.Fatal("usage is nil")
}
if res.Usage.PromptTokens != 10 {
t.Errorf("prompt_tokens = %d, want 10", res.Usage.PromptTokens)
}
if res.Usage.CompletionTokens != 5 {
t.Errorf("completion_tokens = %d, want 5", res.Usage.CompletionTokens)
}
if res.Usage.TotalTokens != 15 {
t.Errorf("total_tokens = %d, want 15", res.Usage.TotalTokens)
if res.Usage.PromptTokens != 10 || res.Usage.CompletionTokens != 5 || res.Usage.TotalTokens != 15 {
t.Errorf("usage = %#v", res.Usage)
}
if res.Usage.PromptTokensDetails == nil || res.Usage.PromptTokensDetails.CachedTokens != 3 {
t.Errorf("cached_tokens unexpected: %#v", res.Usage.PromptTokensDetails)
t.Errorf("prompt token details = %#v", res.Usage.PromptTokensDetails)
}
if res.Usage.CompletionTokensDetails == nil || res.Usage.CompletionTokensDetails.ReasoningTokens != 2 {
t.Errorf("reasoning_tokens unexpected: %#v", res.Usage.CompletionTokensDetails)
t.Errorf("completion token details = %#v", res.Usage.CompletionTokensDetails)
}
if res.Timings == nil {
t.Fatal("timings is nil")
}
if res.Timings.PromptMS != 50.5 {
t.Errorf("prompt_ms = %f, want 50.5", res.Timings.PromptMS)
}
if res.Timings.PredictedMS != 100.0 {
t.Errorf("predicted_ms = %f, want 100.0", res.Timings.PredictedMS)
if res.Timings.PromptMS != 50.5 || res.Timings.PredictedMS != 100 {
t.Errorf("timings = %#v", res.Timings)
}
}
func TestCompletionsStreamEmitsUsageEvent(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/chat/completions" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
w.Header().Set("Content-Type", "text/event-stream")
// Stream some content
fmt.Fprintf(w, "data: %s\n\n",
`{"choices":[{"index":0,"delta":{"role":"assistant"}}]}`)
fmt.Fprintf(w, "data: %s\n\n",
`{"choices":[{"index":0,"delta":{"content":"hello"}}]}`)
// Final chunk with usage
fmt.Fprintf(w, "data: %s\n\n",
`{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}`)
fmt.Fprint(w, "data: [DONE]\n\n")
}))
defer srv.Close()
func TestCompletionsStreamSSEAndMetadata(t *testing.T) {
large := strings.Repeat("x", 70*1024)
client := newCompletionTestClient(t, func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprint(w, ": comment\n")
fmt.Fprintf(w, "data:{\"choices\":[{\"index\":0,\"delta\":{\"content\":%q}}],\n", large)
fmt.Fprint(w, "data: \"usage\":{\"prompt_tokens\":10,\"total_tokens\":15}}\n\n")
fmt.Fprint(w, "data: {\"choices\":[],\"timings\":{\"prompt_ms\":50.5}}\n\n")
fmt.Fprint(w, "data:[DONE]\n\n")
})
client, err := NewClient(Config{URL: srv.URL}, nil, nil)
if err != nil {
t.Fatalf("NewClient: %v", err)
stream := openCompletionTestStream(t, client)
var count int
for stream.Next() {
count++
}
events := make(chan StreamEvent)
errCh := make(chan error, 1)
go func() {
errCh <- client.CompletionsStream(
context.Background(), CompletionsParams{
Model: "test-model",
SystemMessage: "",
MaxIterations: 0,
Messages: []Message{{
Role: RoleUser,
ContentParts: []ContentPart{{
Type: ContentTypeText,
Text: "hi",
}},
}},
}, events,
)
}()
var got []StreamEvent
for evt := range events {
got = append(got, evt)
if err := stream.Err(); err != nil {
t.Fatalf("stream.Err: %v", err)
}
if err := <-errCh; err != nil {
t.Fatalf("CompletionsStream: %v", err)
result := stream.Result()
if count != 2 || result.Message.Text() != large {
t.Fatalf("count = %d, text length = %d", count, len(result.Message.Text()))
}
if result.Usage == nil || result.Usage.PromptTokens != 10 || result.Timings == nil || result.Timings.PromptMS != 50.5 {
t.Errorf("metadata = usage %#v, timings %#v", result.Usage, result.Timings)
}
}
// Expect: delta, done events (usage attached to done event)
wantTypes := []string{
streamEventDelta,
streamEventDone,
func TestCompletionsStreamFailuresPreservePartialResult(t *testing.T) {
tests := []struct {
name string
response string
wantErr error
}{
{
name: "EOF before done",
response: "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"partial\"}}]}\n\n",
wantErr: io.ErrUnexpectedEOF,
},
{
name: "malformed JSON",
response: "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"partial\"}}]}\n\ndata: {bad}\n\n",
},
{
name: "unexpected choice",
response: "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"partial\"}}]}\n\ndata: {\"choices\":[{\"index\":1,\"delta\":{}}]}\n\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := newCompletionTestClient(t, func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprint(w, tt.response)
})
stream := openCompletionTestStream(t, client)
for stream.Next() {
}
if stream.Err() == nil {
t.Fatal("Err = nil")
}
if tt.wantErr != nil && !errors.Is(stream.Err(), tt.wantErr) {
t.Errorf("Err = %v, want %v", stream.Err(), tt.wantErr)
}
result := stream.Result()
if got := result.Message.Text(); got != "partial" {
t.Errorf("partial text = %q", got)
}
})
}
if len(got) != len(wantTypes) {
t.Fatalf("got %d events, want %d: %#v", len(got), len(wantTypes), got)
}
func TestCompletionStreamCloseIsIdempotent(t *testing.T) {
client := newCompletionTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.(http.Flusher).Flush()
<-r.Context().Done()
})
stream := openCompletionTestStream(t, client)
if err := stream.Close(); err != nil {
t.Fatalf("first Close: %v", err)
}
for i, want := range wantTypes {
if got[i].Type != want {
t.Fatalf("event %d type = %q, want %q", i, got[i].Type, want)
}
if err := stream.Close(); err != nil {
t.Fatalf("second Close: %v", err)
}
// Verify usage is on the done event
doneEvt := got[1]
if doneEvt.Usage == nil {
t.Fatal("done event has nil Usage")
if stream.Err() != nil {
t.Errorf("Err after Close = %v", stream.Err())
}
if doneEvt.Usage.PromptTokens != 10 {
t.Errorf("prompt_tokens = %d, want 10", doneEvt.Usage.PromptTokens)
if stream.Next() {
t.Error("Next after Close returned true")
}
}
func TestCompletionsStreamRequiresCompleteDoneEvent(t *testing.T) {
client := newCompletionTestClient(t, func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprint(w, "data: [DONE]")
})
stream := openCompletionTestStream(t, client)
if stream.Next() {
t.Fatal("Next returned true")
}
if doneEvt.Usage.TotalTokens != 15 {
t.Errorf("total_tokens = %d, want 15", doneEvt.Usage.TotalTokens)
if !errors.Is(stream.Err(), io.ErrUnexpectedEOF) {
t.Fatalf("Err = %v, want io.ErrUnexpectedEOF", stream.Err())
}
}
+2 -10
View File
@@ -44,11 +44,7 @@ func (c *Client) ListModels(ctx context.Context) ([]Model, error) {
)
}
var apiError struct {
Error *struct {
Type string `json:"type"`
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
Error *APIError `json:"error"`
}
if err := json.Unmarshal(
body, &apiError,
@@ -63,11 +59,7 @@ func (c *Client) ListModels(ctx context.Context) ([]Model, error) {
),
slog.String("type", apiError.Error.Type),
)
return nil, fmt.Errorf(
"list models: %s: %s",
apiError.Error.Code,
apiError.Error.Message,
)
return nil, fmt.Errorf("list models: %w", apiError.Error)
}
c.log.ErrorContext(
ctx,
+41 -22
View File
@@ -1,6 +1,7 @@
package llm
import (
"log/slog"
"testing"
)
@@ -54,10 +55,9 @@ func TestConfigValidate(t *testing.T) {
{
name: "valid full config",
cfg: Config{
Key: "sk-test-key",
SystemMessage: "You are a helpful assistant.",
Timeout: "30m",
URL: "http://localhost:8080",
Key: "sk-test-key",
Timeout: "30m",
URL: "http://localhost:8080",
},
wantErr: false,
},
@@ -77,22 +77,31 @@ func TestConfigValidate(t *testing.T) {
}
func TestNewClient(t *testing.T) {
log := slog.Default()
tests := []struct {
name string
cfg Config
wantErr bool
log *slog.Logger
wantErr string
}{
{
name: "invalid config",
name: "invalid config precedes nil logger",
cfg: Config{},
wantErr: true,
wantErr: "invalid config: missing URL",
},
{
name: "nil logger",
cfg: Config{
URL: "http://localhost:8080",
},
wantErr: "missing logger",
},
{
name: "valid config without timeout",
cfg: Config{
URL: "http://localhost:8080",
},
wantErr: false,
log: log,
},
{
name: "valid config with timeout",
@@ -100,32 +109,42 @@ func TestNewClient(t *testing.T) {
URL: "http://localhost:8080",
Timeout: "10m",
},
wantErr: false,
log: log,
},
{
name: "valid config with all fields",
cfg: Config{
Key: "test-key",
SystemMessage: "Test message",
Timeout: "5m",
URL: "http://localhost:8080",
Key: "test-key",
Timeout: "5m",
URL: "http://localhost:8080",
},
wantErr: false,
log: log,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client, err := NewClient(tt.cfg, nil, nil)
if (err != nil) != tt.wantErr {
t.Errorf(
"NewClient() error = %v, wantErr %v",
err, tt.wantErr,
)
client, err := NewClient(tt.cfg, tt.log)
if tt.wantErr != "" {
if err == nil {
t.Fatalf("NewClient() error = nil, want %q", tt.wantErr)
}
if err.Error() != tt.wantErr {
t.Errorf(
"NewClient() error = %q, want %q",
err, tt.wantErr,
)
}
if client != nil {
t.Errorf("NewClient() client = %#v, want nil", client)
}
return
}
if !tt.wantErr && client == nil {
t.Error("NewClient() returned nil client")
if err != nil {
t.Fatalf("NewClient() error = %v, want nil", err)
}
if client == nil {
t.Error("NewClient() client = nil, want non-nil")
}
})
}
-77
View File
@@ -1,77 +0,0 @@
package llm
import (
"context"
"runtime"
"sync"
"log/slog"
)
// callTools executes each tool call in the message concurrently and returns
// the resulting tool result messages in the same order as the input calls.
func (c *Client) callTools(ctx context.Context, msg Message) []Message {
var (
results = make([]Message, len(msg.ToolCalls))
sem = make(chan struct{}, runtime.NumCPU()*2)
wg sync.WaitGroup
)
for i, tc := range msg.ToolCalls {
wg.Add(1)
go func(idx int, tc ToolCall) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
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),
)
results[idx] = Message{
Role: RoleTool,
ContentParts: []ContentPart{{
Type: ContentTypeText,
Text: err.Error(),
}},
Name: tc.Function.Name,
ToolCallID: tc.ID,
}
return
}
c.log.InfoContext(
ctx,
"called tool",
slog.String("name", tc.Function.Name),
)
formatted := result.FormatResult()
results[idx] = Message{
Role: RoleTool,
ContentParts: []ContentPart{{
Type: ContentTypeText,
Text: formatted,
}},
Name: tc.Function.Name,
ToolCallID: tc.ID,
}
}(i, tc)
}
wg.Wait()
return results
}
+373 -164
View File
@@ -1,16 +1,5 @@
// Package llm provides an OpenAI-compatible client for LLM interactions.
// 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 implements an OpenAI-compatible inference client and its
// protocol types.
package llm
@@ -19,195 +8,350 @@ import (
"strings"
)
// Role constants for messages.
const (
RoleSystem = "system"
RoleUser = "user"
RoleAssistant = "assistant"
RoleTool = "tool"
RoleDeveloper = "developer"
)
// APIError contains the structured error details returned by an
// OpenAI-compatible API. Error values are defined by the server. Compatible
// servers need not use the same set of types or codes. Callers should use
// Message for diagnostics and Type and Code for programmatic error handling.
//
// See [OpenAI API error codes].
//
// [OpenAI API error codes]: https://developers.openai.com/api/docs/guides/error-codes
type APIError struct {
// Code identifies the precise error condition reported by the server.
// Callers may use it to distinguish errors that have the same Type.
Code string `json:"code"`
// APITool defines a tool available to the LLM in the API request format.
// Message explains what went wrong. Its wording is server-defined and
// should not be parsed by callers.
Message string `json:"message"`
// Type identifies the general kind of failure reported by the server.
// Callers may use it to handle related error conditions as a group.
Type string `json:"type"`
}
// Error returns the server-defined code and diagnostic message.
func (e *APIError) Error() string {
return e.Code + ": " + e.Message
}
// APITool describes a tool that a model may call. Function must be set when
// Type is ToolTypeFunction.
type APITool struct {
Type string `json:"type"` // "function"
// Function defines the callable function when Type is ToolTypeFunction.
Function *FunctionDef `json:"function,omitempty"`
// Type selects the representation of the tool.
Type ToolType `json:"type"`
}
// ChatRequest is the request body for chat completions.
// ChatRequest is the request body of POST /chat/completions.
//
// See [OpenAI Chat Completions API].
//
// [OpenAI Chat Completions API]: https://platform.openai.com/docs/api-reference/chat/create
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"`
ChatTemplateKWARGS map[string]any `json:"chat_template_kwargs,omitempty"`
ThinkingBudgetTokens *int `json:"thinking_budget_tokens,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
// ChatTemplateKWARGS contains arguments made available to the
// endpoint's chat template.
// It is a llama.cpp extension to the OpenAI API.
ChatTemplateKWARGS map[string]any `json:"chat_template_kwargs,omitempty"`
// FrequencyPenalty adjusts token likelihood according to frequency in
// the generated text. Nil leaves the choice to the endpoint.
FrequencyPenalty *float32 `json:"frequency_penalty,omitempty"`
// LogProbs requests log probabilities for generated tokens.
LogProbs bool `json:"logprobs,omitempty"`
// MaxCompletionTokens bounds generated tokens, including reasoning
// tokens.
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
// MaxTokens is the deprecated predecessor of MaxCompletionTokens.
MaxTokens int `json:"max_tokens,omitempty"`
// Messages is the conversation presented to the model, in order.
Messages []Message `json:"messages"`
// Model identifies the model to use according to the endpoint.
Model string `json:"model"`
// N is the number of alternatives to generate.
N int `json:"n,omitempty"`
// ParallelToolCalls controls whether the model may request multiple
// function calls in one turn. Nil leaves the choice to the endpoint.
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"`
// PresencePenalty adjusts token likelihood according to whether a
// token has already appeared. Nil leaves the choice to the endpoint.
PresencePenalty *float32 `json:"presence_penalty,omitempty"`
// ResponseFormat constrains the representation of generated content.
ResponseFormat *ResponseFormat `json:"response_format,omitempty"`
// Seed requests deterministic sampling on endpoints that support it.
// Equal seeds and parameters do not guarantee equal results.
Seed *int `json:"seed,omitempty"`
// ServiceTier selects the endpoint's request-processing tier.
ServiceTier string `json:"service_tier,omitempty"`
// Stop contains sequences that end generation.
// The returned text excludes the matched sequence.
Stop []string `json:"stop,omitempty"`
// Stream requests a sequence of StreamChunk values instead of
// ChatResponse.
Stream bool `json:"stream,omitempty"`
// StreamOptions configures a streaming response.
// It is meaningful only when Stream is true.
StreamOptions *StreamOptions `json:"stream_options,omitempty"`
// Temperature controls sampling randomness.
// Nil leaves the choice to the endpoint.
Temperature *float32 `json:"temperature,omitempty"`
// ThinkingBudgetTokens is a llama.cpp extension controlling the token
// budget for reasoning. Nil omits it, a negative value requests an
// unrestricted budget, zero requests an immediate end, and a positive
// value sets the budget.
ThinkingBudgetTokens *int `json:"thinking_budget_tokens,omitempty"`
// ToolChoice controls whether the model may or must call a tool.
// It accepts a mode string or an endpoint-defined object selecting a
// particular tool.
ToolChoice any `json:"tool_choice,omitempty"`
// Tools describes the tools available to the model.
Tools []APITool `json:"tools,omitempty"`
// TopLogProbs is the number of most likely tokens reported at each
// position when LogProbs is true.
TopLogProbs int `json:"top_logprobs,omitempty"`
// TopP controls nucleus sampling.
// Nil leaves the choice to the endpoint.
TopP *float32 `json:"top_p,omitempty"`
}
// Usage represents token usage from the API response.
// Usage reports input, output, and total token counts for a completion.
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
PromptTokensDetails *TokenDetails `json:"prompt_tokens_details,omitempty"`
// CompletionTokens is the number of tokens generated by the model.
CompletionTokens int `json:"completion_tokens"`
// CompletionTokensDetails breaks down CompletionTokens by category.
CompletionTokensDetails *TokenDetails `json:"completion_tokens_details,omitempty"`
// PromptTokens is the number of tokens in the input.
PromptTokens int `json:"prompt_tokens"`
// PromptTokensDetails breaks down PromptTokens by category.
PromptTokensDetails *TokenDetails `json:"prompt_tokens_details,omitempty"`
// TotalTokens is the sum of PromptTokens and CompletionTokens.
TotalTokens int `json:"total_tokens"`
}
// TokenDetails breaks down token usage by category.
// TokenDetails reports token categories within a Usage count. A zero value may
// mean either that no tokens belong to the category or that it was not
// reported.
type TokenDetails struct {
// CachedTokens is the number of input tokens served from a cache.
CachedTokens int `json:"cached_tokens,omitempty"`
// ReasoningTokens is the number of output tokens used for reasoning.
ReasoningTokens int `json:"reasoning_tokens,omitempty"`
CachedTokens int `json:"cached_tokens,omitempty"`
}
// Timings is a llama.cpp-specific extension to the chat completion
// response. It is not part of the OpenAI API spec.
// Timings reports llama.cpp prompt-processing and token-generation performance.
// The N fields are token counts, MS fields are elapsed milliseconds,
// PerTokenMS fields are milliseconds per token, and PerSecond fields are tokens
// per second. Timings is a llama.cpp extension to the OpenAI API.
//
// See [llama.cpp API documentation].
//
// [llama.cpp API documentation]: https://llama.app/docs/api
type Timings struct {
PromptN int `json:"prompt_n"`
PromptMS float64 `json:"prompt_ms"`
PromptPerTokenMS float64 `json:"prompt_per_token_ms"`
PromptPerSecond float64 `json:"prompt_per_second"`
PredictedN int `json:"predicted_n"`
PredictedMS float64 `json:"predicted_ms"`
// PredictedMS is the time spent generating output, in milliseconds.
PredictedMS float64 `json:"predicted_ms"`
// PredictedN is the number of generated tokens.
PredictedN int `json:"predicted_n"`
// PredictedPerSecond is the output-generation rate in tokens per
// second.
PredictedPerSecond float64 `json:"predicted_per_second"`
// PredictedPerTokenMS is the mean output-generation time per token, in
// milliseconds.
PredictedPerTokenMS float64 `json:"predicted_per_token_ms"`
PredictedPerSecond float64 `json:"predicted_per_second"`
// PromptMS is the time spent processing the prompt, in milliseconds.
PromptMS float64 `json:"prompt_ms"`
// PromptN is the number of prompt tokens processed.
PromptN int `json:"prompt_n"`
// PromptPerSecond is the prompt-processing rate in tokens per second.
PromptPerSecond float64 `json:"prompt_per_second"`
// PromptPerTokenMS is the mean prompt-processing time per token, in
// milliseconds.
PromptPerTokenMS float64 `json:"prompt_per_token_ms"`
}
// ChatResponse is the non-streaming response from the API.
// ChatResponse is the body of a non-streaming chat completion
// response.
type ChatResponse struct {
ID string `json:"id"`
Model string `json:"model"`
// Choices contains the generated alternatives.
Choices []Choice `json:"choices"`
Usage *Usage `json:"usage,omitempty"`
// ID identifies the completion.
ID string `json:"id"`
// Model identifies the model that generated the response.
Model string `json:"model"`
// Timings contains llama.cpp performance data, when reported.
Timings *Timings `json:"timings,omitempty"`
// Usage contains token counts, when reported.
Usage *Usage `json:"usage,omitempty"`
}
// Choice is a single choice in a ChatResponse.
// Choice is one generated alternative in a non-streaming response. Index is its
// position among the alternatives, and FinishReason reports why generation
// ended.
type Choice struct {
Index int `json:"index"`
Message Message `json:"message"`
FinishReason string `json:"finish_reason"`
// FinishReason is the endpoint's reason for ending generation.
FinishReason string `json:"finish_reason"`
// Index is the position of this choice in the response.
Index int `json:"index"`
// Message is the generated message.
Message Message `json:"message"`
}
// ContentType identifies the kind of data in a ContentPart.
// ContentType identifies the representation of a ContentPart. Which content
// types are valid depends on the message role and the endpoint.
//
// User messages (ChatCompletionContentPart) support: text, image_url,
// input_audio, file.
// See [OpenAI Chat Completions API].
//
// Assistant messages (ChatCompletionAssistantMessageParam) support:
// text, refusal.
//
// See: https://platform.openai.com/docs/api-reference/chat/create
// [OpenAI Chat Completions API]: https://platform.openai.com/docs/api-reference/chat/create
type ContentType string
const (
ContentTypeText ContentType = "text"
ContentTypeFile ContentType = "file"
ContentTypeImageURL ContentType = "image_url"
ContentTypeInputAudio ContentType = "input_audio"
ContentTypeFile ContentType = "file"
ContentTypeRefusal ContentType = "refusal"
ContentTypeText ContentType = "text"
)
// ContentPart represents a single part of a multi-content message.
// ContentPart is one element of a message's content array. Type determines
// which payload field is meaningful: File, ImageURL, InputAudio, Refusal, or
// Text. The package does not validate that correspondence.
type ContentPart struct {
Type ContentType `json:"type"`
Text string `json:"text"`
ImageURL *ImageURL `json:"image_url,omitempty"`
// File contains the payload for ContentTypeFile.
File *FileInput `json:"file,omitempty"`
// ImageURL contains the payload for ContentTypeImageURL.
ImageURL *ImageURL `json:"image_url,omitempty"`
// InputAudio contains the payload for ContentTypeInputAudio.
InputAudio *InputAudio `json:"input_audio,omitempty"`
File *FileInput `json:"file,omitempty"`
Refusal string `json:"refusal"`
// Refusal contains the payload for ContentTypeRefusal.
Refusal string `json:"refusal,omitempty"`
// Text contains the payload for ContentTypeText.
Text string `json:"text,omitempty"`
// Type identifies which payload field is meaningful.
Type ContentType `json:"type"`
}
// Delta is a partial message update in a streaming response.
// Delta is an incremental message update in a StreamChunk. Reasoning and
// ReasoningContent are alternative field names used by compatible endpoints;
// StreamAccumulator preserves fragments from both.
type Delta struct {
Role string `json:"role,omitempty"`
Content string `json:"content,omitempty"`
Refusal string `json:"refusal,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"`
Reasoning string `json:"reasoning,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
// Content is the next fragment of message text.
Content string `json:"content,omitempty"`
// Reasoning is the next reasoning fragment under the reasoning field
// name.
Reasoning string `json:"reasoning,omitempty"`
// ReasoningContent is the next reasoning fragment under the
// reasoning_content field name.
ReasoningContent string `json:"reasoning_content,omitempty"`
// Refusal is the next fragment of refusal text.
Refusal string `json:"refusal,omitempty"`
// Role identifies the message role when supplied by the endpoint.
Role Role `json:"role,omitempty"`
// ToolCalls contains the next fragments of one or more tool calls.
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
}
// FileInput holds file data for file content parts.
// FileInput identifies file content either inline through FileData or by a
// server-assigned FileID.
type FileInput struct {
// FileData is the base64-encoded contents of an inline file.
FileData string `json:"file_data,omitempty"`
FileID string `json:"file_id,omitempty"`
// FileID references a file previously uploaded to the server.
FileID string `json:"file_id,omitempty"`
// Filename names an inline file supplied through FileData.
Filename string `json:"filename,omitempty"`
}
// Function holds the name and arguments of a tool call.
// Function identifies a called function and carries its arguments as a JSON
// string. The model may emit invalid JSON, so callers must validate Arguments
// before invoking the function.
type Function struct {
Name string `json:"name,omitempty"`
// Arguments is the JSON-encoded argument object generated by the model.
Arguments string `json:"arguments,omitempty"`
// Name identifies the function to call.
Name string `json:"name,omitempty"`
}
// FunctionDef defines a function that can be called by the LLM.
// FunctionDef declares a function the model may call.
type FunctionDef struct {
Name string `json:"name"`
// Description states what the function does and when to call it.
Description string `json:"description,omitempty"`
Parameters any `json:"parameters"` // JSON schema
Strict bool `json:"strict,omitempty"`
// Name identifies the function.
Name string `json:"name"`
// Parameters is a JSON Schema describing the arguments accepted by the
// function.
Parameters any `json:"parameters"`
// Strict requests exact conformance to Parameters, where the server
// supports it.
Strict bool `json:"strict,omitempty"`
}
// ImageURL holds an image reference for multi-modal messages.
// ImageDetail selects the fidelity used when processing an image. Auto lets the
// endpoint choose.
type ImageDetail string
const (
ImageDetailAuto ImageDetail = "auto"
ImageDetailHigh ImageDetail = "high"
ImageDetailLow ImageDetail = "low"
)
// ImageURL carries an image content part.
type ImageURL struct {
URL string `json:"url"`
Detail string `json:"detail,omitempty"` // "auto", "low", "high"
// Detail is optional; when omitted the server chooses.
Detail ImageDetail `json:"detail,omitempty"`
// URL is a public image URL or a base64 data URI.
URL string `json:"url"`
}
// InputAudio holds audio data for multi-modal messages.
// AudioFormat names an audio encoding accepted in an input_audio
// part.
type AudioFormat string
const (
AudioFormatWAV AudioFormat = "wav"
AudioFormatMP3 AudioFormat = "mp3"
)
// InputAudio carries base64-encoded WAV or MP3 audio.
type InputAudio struct {
Data string `json:"data"`
Format string `json:"format"` // "wav", "mp3"
// Data is base64-encoded audio data.
Data string `json:"data"`
// Format identifies the encoding of Data.
Format AudioFormat `json:"format"`
}
// 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.
// Message is one message in a conversation or completion response.
//
// Refusal handling:
// The JSON content field may be a string or an array. UnmarshalJSON normalizes
// either representation into ContentParts; marshaling a Message encodes
// ContentParts as an array.
//
// In API responses (ChatCompletionMessage), the model sends refusal as a
// top-level field alongside content:
// See [OpenAI Chat Completions API].
//
// "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
// [OpenAI Chat Completions API]: 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"`
Reasoning string `json:"reasoning,omitempty"`
Name string `json:"name,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
// ContentParts holds the message content in parts; it is the
// in-memory form of the JSON "content" field.
ContentParts []ContentPart `json:"content,omitempty"`
// Name optionally identifies the sender.
Name string `json:"name,omitempty"`
// Reasoning and ReasoningContent are alternative wire fields used by
// compatible servers. After unmarshalling, ReasoningContent contains
// either representation, with reasoning_content taking precedence when
// both occur.
Reasoning string `json:"reasoning,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"`
// Refusal contains the endpoint's refusal text, when present.
Refusal string `json:"refusal,omitempty"`
// Role identifies the author or purpose of the message.
Role Role `json:"role"`
// ToolCallID links a tool response to the call it answers.
ToolCallID string `json:"tool_call_id,omitempty"`
// ToolCalls contains calls requested by an assistant message.
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
}
// Text returns the concatenated text from all text content parts.
// Text concatenates the Text payload of every text content part in order. It
// ignores non-text parts and does not fall back to Refusal or reasoning text.
func (m *Message) Text() string {
var b strings.Builder
for _, p := range m.ContentParts {
@@ -218,8 +362,10 @@ func (m *Message) Text() string {
return b.String()
}
// UnmarshalJSON handles the polymorphic content field which can be
// either a string or an array of ContentPart objects.
// UnmarshalJSON decodes a message while normalizing a string-valued content
// field to one text ContentPart. An array-valued content field is decoded in
// order. If reasoning_content is absent, reasoning is copied into
// ReasoningContent so callers can use the latter for either server spelling.
func (m *Message) UnmarshalJSON(data []byte) error {
type Alias Message
aux := &struct {
@@ -235,6 +381,8 @@ func (m *Message) UnmarshalJSON(data []byte) error {
case string:
m.ContentParts = []ContentPart{{Type: ContentTypeText, Text: c}}
case []any:
// Array content replaces any content already held by the Message.
parts := make([]ContentPart, 0, len(c))
for _, raw := range c {
partBytes, err := json.Marshal(raw)
if err != nil {
@@ -244,68 +392,129 @@ func (m *Message) UnmarshalJSON(data []byte) error {
if err := json.Unmarshal(partBytes, &part); err != nil {
return err
}
m.ContentParts = append(m.ContentParts, part)
parts = append(parts, part)
}
m.ContentParts = parts
}
// Normalize: if ReasoningContent is empty but Reasoning is set, use Reasoning
// If ReasoningContent is empty but Reasoning is set, use Reasoning.
if m.ReasoningContent == "" && m.Reasoning != "" {
m.ReasoningContent = m.Reasoning
}
return nil
}
// Model represents an available model from the API.
// Model describes one model exposed by the server's /models
// endpoint.
//
// See [OpenAI Models API].
//
// [OpenAI Models API]: https://platform.openai.com/docs/api-reference/models/list
type Model struct {
ID string `json:"id"`
Created int64 `json:"created"`
Object string `json:"object"`
OwnedBy string `json:"owned_by"`
Meta *ModelMeta `json:"meta,omitempty"`
ContextLength int `json:"context_length,omitempty"`
// ContextLength is the model's context window in tokens, when reported.
ContextLength int `json:"context_length,omitempty"`
// Created is the model's creation time as Unix seconds.
Created int64 `json:"created"`
// ID identifies the model in inference requests.
ID string `json:"id"`
// Meta contains endpoint-specific model metadata, when reported.
Meta *ModelMeta `json:"meta,omitempty"`
// Object is the endpoint's object type for the model entry.
Object string `json:"object"`
// OwnedBy identifies the organization that owns the model, when
//reported.
OwnedBy string `json:"owned_by"`
}
// ModelMeta contains optional model metadata from the provider.
// llama.cpp includes n_ctx (configured context size) and
// n_ctx_train (model's native training context).
// ModelMeta contains llama.cpp model metadata. Context-size fields are measured
// in tokens and are zero when omitted.
//
// See [llama.cpp API documentation].
//
// [llama.cpp API documentation]: https://llama.app/docs/api
type ModelMeta struct {
NCtx int `json:"n_ctx,omitempty"`
// NCtx is the context size with which llama.cpp loaded the model.
NCtx int `json:"n_ctx,omitempty"`
// NCtxTrain is the model's training context size.
NCtxTrain int `json:"n_ctx_train,omitempty"`
}
// ModelsResponse is the response for GET /models.
// ModelsResponse is the response body of GET /models.
type ModelsResponse struct {
Object string `json:"object"`
Data []Model `json:"data"`
// Data contains the models exposed by the endpoint.
Data []Model `json:"data"`
// Object is the endpoint's object type for the response.
Object string `json:"object"`
}
// ResponseFormat specifies the format of the response.
// Role identifies the author or purpose of a message.
type Role string
const (
RoleAssistant Role = "assistant"
RoleDeveloper Role = "developer"
RoleSystem Role = "system"
RoleTool Role = "tool"
RoleUser Role = "user"
)
// ResponseFormat constrains the format of the generated response.
type ResponseFormat struct {
Type string `json:"type"`
JSONSchema any `json:"json_schema,omitempty"`
// JSONSchema defines the required output schema when Type is
// json_schema.
JSONSchema any `json:"json_schema,omitempty"`
// Type selects text, json_object, or json_schema output.
Type string `json:"type"`
}
// StreamChunk is a single SSE chunk from a streaming response.
// StreamChunk is one data event in a streaming response. Delta fields are
// incremental and must be combined in order to reconstruct a message. A chunk
// may contain Usage or Timings without containing a choice.
type StreamChunk struct {
ID string `json:"id"`
Model string `json:"model"`
// Choices contains incremental updates for generated alternatives.
// It may be empty in a metadata-only chunk.
Choices []struct {
Index int `json:"index"`
Delta Delta `json:"delta"`
// Index identifies the choice updated by this delta.
Index int `json:"index"`
// Delta contains the incremental message update.
Delta Delta `json:"delta"`
// FinishReason is set when generation for the choice ends.
FinishReason string `json:"finish_reason,omitempty"`
} `json:"choices"`
Usage *Usage `json:"usage,omitempty"`
// ID identifies the completion to which the chunk belongs.
ID string `json:"id"`
// Model identifies the model generating the response.
Model string `json:"model"`
// Timings contains llama.cpp performance data, when reported.
Timings *Timings `json:"timings,omitempty"`
// Usage contains aggregate token counts, when reported.
Usage *Usage `json:"usage,omitempty"`
}
// StreamOptions controls streaming behavior.
// StreamOptions configures a streaming response.
type StreamOptions struct {
// IncludeUsage requests an additional chunk containing aggregate token
// usage before the terminal data marker. If the stream is interrupted,
// that chunk may not arrive.
IncludeUsage bool `json:"include_usage,omitempty"`
}
// ToolCall represents a tool call made by the LLM.
// ToolCall is one tool call requested by the model.
type ToolCall struct {
Index *int `json:"index,omitempty"` // streaming only
ID string `json:"id,omitempty"`
Type string `json:"type"` // "function"
// Function identifies the function and its generated arguments.
Function Function `json:"function"`
// ID identifies the call. The corresponding tool response carries the
// same value in Message.ToolCallID.
ID string `json:"id,omitempty"`
// Index identifies the call whose fragments a streaming delta
// contains. It is omitted from non-streaming responses.
Index *int `json:"index,omitempty"`
// Type identifies the kind of tool being called.
Type ToolType `json:"type"`
}
// ToolType identifies the kind of tool in a declaration or tool call.
type ToolType string
const (
ToolTypeFunction ToolType = "function"
)
+51 -73
View File
@@ -5,8 +5,6 @@ import (
"log/slog"
"os"
"testing"
"code.chimeric.al/chimerical/odidere/internal/config"
)
// testURL returns the LLM endpoint for integration tests.
@@ -45,7 +43,7 @@ func TestIntegration_ListModels(t *testing.T) {
Key: testKey(t),
Timeout: "30s",
}
client, err := NewClient(cfg, nil, slog.Default())
client, err := NewClient(cfg, slog.Default())
if err != nil {
t.Fatalf("NewClient: %v", err)
}
@@ -71,16 +69,14 @@ func TestIntegration_Completions(t *testing.T) {
Key: testKey(t),
Timeout: "60s",
}
client, err := NewClient(cfg, nil, slog.Default())
client, err := NewClient(cfg, slog.Default())
if err != nil {
t.Fatalf("NewClient: %v", err)
}
res, err := client.Completions(
context.Background(), CompletionsParams{
Model: testModel(t),
SystemMessage: "",
MaxIterations: 0,
Model: testModel(t),
Messages: []Message{{
Role: RoleUser,
ContentParts: []ContentPart{{
@@ -93,10 +89,7 @@ func TestIntegration_Completions(t *testing.T) {
if err != nil {
t.Fatalf("Completions: %v", err)
}
if len(res.Messages) == 0 {
t.Fatal("expected at least one message")
}
text := res.Messages[len(res.Messages)-1].Text()
text := res.Message.Text()
if text == "" {
t.Error("final message has no text")
}
@@ -109,84 +102,54 @@ func TestIntegration_CompletionsStream(t *testing.T) {
Key: testKey(t),
Timeout: "60s",
}
client, err := NewClient(cfg, nil, slog.Default())
client, err := NewClient(cfg, 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(), CompletionsParams{
Model: testModel(t),
SystemMessage: "",
MaxIterations: 0,
Messages: []Message{{
Role: RoleUser,
ContentParts: []ContentPart{{
Type: "text",
Text: "Say hello in three words.",
}},
stream, err := client.CompletionsStream(
context.Background(),
CompletionsParams{
Model: testModel(t),
Messages: []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 {
}},
},
)
if err != nil {
t.Fatalf("CompletionsStream: %v", err)
}
if len(messages) == 0 {
t.Fatal("expected at least one message")
defer stream.Close()
for stream.Next() {
}
text := messages[len(messages)-1].Text()
if text == "" {
if err := stream.Err(); err != nil {
t.Fatalf("stream.Err: %v", err)
}
result := stream.Result()
if result.Message.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{
client, err := NewClient(Config{
URL: testURL(t),
Key: testKey(t),
Timeout: "60s",
}
client, err := NewClient(cfg, reg, slog.Default())
}, slog.Default())
if err != nil {
t.Fatalf("NewClient: %v", err)
}
res, err := client.Completions(
context.Background(), CompletionsParams{
Model: testModel(t),
SystemMessage: "",
MaxIterations: 10,
context.Background(),
CompletionsParams{
Model: testModel(t),
Messages: []Message{{
Role: RoleUser,
ContentParts: []ContentPart{{
@@ -194,17 +157,32 @@ func TestIntegration_ToolCalling(t *testing.T) {
Text: "Use the echo tool to say 'hello'",
}},
}},
Tools: []APITool{{
Type: "function",
Function: &FunctionDef{
Name: "echo",
Description: "Echo back the input string",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"input": map[string]any{"type": "string"},
},
"required": []string{"input"},
},
},
}},
},
)
if err != nil {
t.Fatalf("Completions: %v", err)
}
if len(res.Messages) == 0 {
t.Fatal("expected at least one message")
if len(res.Message.ToolCalls) == 0 {
t.Fatal("expected a tool call")
}
// The final message should be a non-tool-call assistant message.
final := res.Messages[len(res.Messages)-1]
if len(final.ToolCalls) > 0 {
t.Error("final message still has tool calls")
if res.Message.ToolCalls[0].Function.Name != "echo" {
t.Errorf(
"tool name = %q, want echo",
res.Message.ToolCalls[0].Function.Name,
)
}
}
+159
View File
@@ -0,0 +1,159 @@
package llm
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
)
func TestContentPartMarshalJSON(t *testing.T) {
tests := []struct {
name string
part ContentPart
want string
}{
{
name: "text",
part: ContentPart{
Type: ContentTypeText,
Text: "hello",
},
want: `{"text":"hello","type":"text"}`,
},
{
name: "refusal",
part: ContentPart{
Type: ContentTypeRefusal,
Refusal: "cannot comply",
},
want: `{"refusal":"cannot comply","type":"refusal"}`,
},
{
name: "image URL",
part: ContentPart{
Type: ContentTypeImageURL,
ImageURL: &ImageURL{
URL: "https://example.com/image.png",
},
},
want: `{"image_url":{"url":"https://example.com/image.png"},"type":"image_url"}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := json.Marshal(tt.part)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
if string(got) != tt.want {
t.Errorf("json.Marshal() = %s, want %s", got, tt.want)
}
})
}
}
func TestClientReturnsAPIError(t *testing.T) {
tests := []struct {
name string
call func(*Client) error
wantErr string
}{
{
name: "completions",
call: func(client *Client) error {
_, err := client.Completions(t.Context(), CompletionsParams{})
return err
},
wantErr: "completions: model_not_found: model unavailable",
},
{
name: "completions stream",
call: func(client *Client) error {
_, err := client.CompletionsStream(
t.Context(), CompletionsParams{},
)
return err
},
wantErr: "completions stream: model_not_found: model unavailable",
},
{
name: "list models",
call: func(client *Client) error {
_, err := client.ListModels(context.Background())
return err
},
wantErr: "list models: model_not_found: model unavailable",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(
w http.ResponseWriter, _ *http.Request,
) {
w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w, `{"error":{"code":"model_not_found","message":"model unavailable","type":"invalid_request_error"}}`)
}))
defer server.Close()
log := slog.New(slog.NewTextHandler(io.Discard, nil))
client, err := NewClient(Config{URL: server.URL}, log)
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
err = tt.call(client)
if err == nil {
t.Fatal("call error = nil, want API error")
}
if err.Error() != tt.wantErr {
t.Errorf("call error = %q, want %q", err, tt.wantErr)
}
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("errors.As(%T) = false, want *APIError", err)
}
if apiErr.Code != "model_not_found" {
t.Errorf("APIError.Code = %q, want %q", apiErr.Code, "model_not_found")
}
if apiErr.Message != "model unavailable" {
t.Errorf("APIError.Message = %q, want %q", apiErr.Message, "model unavailable")
}
if apiErr.Type != "invalid_request_error" {
t.Errorf(
"APIError.Type = %q, want %q",
apiErr.Type,
"invalid_request_error",
)
}
})
}
}
func TestMessageUnmarshalJSONReplacesArrayContent(t *testing.T) {
message := Message{
ContentParts: []ContentPart{{
Type: ContentTypeText,
Text: "old",
}},
}
err := json.Unmarshal(
[]byte(`{"content":[{"type":"text","text":"new"}]}`),
&message,
)
if err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if got := message.Text(); got != "new" {
t.Errorf("Message.Text() = %q, want %q", got, "new")
}
}
-335
View File
@@ -1,335 +0,0 @@
package llm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"strings"
"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
}
// binaryOutputPrefix is the message shown when tool output is detected as binary.
const binaryOutputPrefix = "error: binary output detected: "
// ToolResult holds the structured output from a tool execution.
type ToolResult struct {
Stdout []byte
Stderr []byte
ProcessState *os.ProcessState
}
// FormatResult formats the tool result using the structured label-based
// format with stdout, stderr, and exit code sections.
//
// http.DetectContentType implements the WHATWG MIME-sniffing spec and
// returns text/plain for any content consisting entirely of printable
// characters and whitespace. Text-based formats like JSON, JavaScript,
// and XML will be detected as text/plain; charset=utf-8. We assume
// checking for the "text/" prefix is sufficient.
func (r *ToolResult) FormatResult() string {
var buf strings.Builder
fmt.Fprintln(&buf, "▸ stdout:")
if len(r.Stdout) > 0 {
ctype := http.DetectContentType(r.Stdout)
if !strings.HasPrefix(ctype, "text/") {
fmt.Fprintf(&buf, binaryOutputPrefix+"%s\n", ctype)
} else {
buf.Write(r.Stdout)
if r.Stdout[len(r.Stdout)-1] != '\n' {
fmt.Fprint(&buf, "\n")
}
}
}
fmt.Fprintln(&buf, "▸ stderr:")
if len(r.Stderr) > 0 {
ctype := http.DetectContentType(r.Stderr)
if !strings.HasPrefix(ctype, "text/") {
fmt.Fprintf(&buf, binaryOutputPrefix+"%s\n", ctype)
} else {
buf.Write(r.Stderr)
if r.Stderr[len(r.Stderr)-1] != '\n' {
fmt.Fprint(&buf, "\n")
}
}
}
exitCode := r.ProcessState.ExitCode()
fmt.Fprintf(&buf, "┗ exit: %d", exitCode)
// Include signal info when the process was killed by a signal.
if exitCode == -1 {
fmt.Fprintf(&buf, " (%s)", r.ProcessState.String())
}
fmt.Fprintln(&buf)
return buf.String()
}
// Execute runs a tool by name with the provided JSON arguments.
// It expands argument templates, executes the command as a subprocess, and
// returns a structured result with stdout, stderr, and exit code.
// The context can be used for cancellation; tool-specific timeouts are
// applied on top of any context deadline.
//
// Start failures (process never ran) return an error directly.
func (r *Registry) Execute(
ctx context.Context, name string, args string,
) (*ToolResult, error) {
tool, ok := r.tools[name]
if !ok {
return nil, fmt.Errorf("unknown tool: %q", name)
}
// Evaluate argument templates.
cmdArgs, err := tool.ParseArguments(args)
if err != nil {
return nil, fmt.Errorf(
"tool %q: parse arguments: %w", name, 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
err = cmd.Run()
// Extract exit code and handle errors.
// After cmd.Run() returns, the possible error cases are:
//
// 1. Context error (ctx.Err() != nil): the context was cancelled or
// timed out. If the process had started, it was killed and
// ProcessState is set. If it had not, Process is nil.
//
// 2. Start failure (cmd.Process == nil): the executable was not found,
// the fork failed, or the context was already done before Start.
// No stdout/stderr to capture.
//
// 3. Process exited with error (err is *exec.ExitError): the process
// ran but exited with a non-zero status or was killed by a signal.
// ProcessState is set and contains the exit code.
//
// In all cases where cmd.Process != nil, ProcessState is guaranteed
// to be set because Start() succeeded.
if err != nil {
switch {
// Context was cancelled or timed out. The process was killed if
// it had started. Distinguish tool timeout from other context
// errors so we can return an appropriate message.
case ctx.Err() != nil:
if ctx.Err() == context.DeadlineExceeded &&
tool.timeout > 0 {
// Tool-specific timeout. Return a clear
// message with the configured duration.
return nil, fmt.Errorf(
"tool %q: timed out after %v",
name, tool.timeout,
)
}
// Context cancelled or parent deadline exceeded.
return nil, fmt.Errorf("tool %q: %w", name, ctx.Err())
// Process never started. No ProcessState, no output.
// The error is the raw start failure (e.g., *PathError).
case cmd.Process == nil:
return nil, fmt.Errorf("tool %q: %w", name, err)
// Process ran and exited with non-zero status or was killed by a signal.
// ProcessState is set; return it along with captured output.
default:
return &ToolResult{
Stdout: stdout.Bytes(),
Stderr: stderr.Bytes(),
ProcessState: cmd.ProcessState,
}, nil
}
}
// No error. Process exited with status 0.
return &ToolResult{
Stdout: stdout.Bytes(),
Stderr: stderr.Bytes(),
ProcessState: cmd.ProcessState,
}, nil
}
-389
View File
@@ -1,389 +0,0 @@
package llm
import (
"bytes"
"context"
"os/exec"
"strings"
"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")
}
}
func TestToolResult_FormatResult(t *testing.T) {
// Get real ProcessState values from actual commands.
cmdOK := exec.Command("sh", "-c", "exit 0")
if err := cmdOK.Run(); err != nil {
t.Fatalf("cmdOK: %v", err)
}
cmdFail := exec.Command("sh", "-c", "exit 3")
cmdFail.Run() // expect non-nil error
tests := []struct {
name string
result ToolResult
wantContains []string
}{
{
name: "success with stdout",
result: ToolResult{Stdout: []byte("hello\n"), Stderr: nil, ProcessState: cmdOK.ProcessState},
wantContains: []string{"▸ stdout:", "hello", "▸ stderr:", "┗ exit: 0"},
},
{
name: "failure with stderr",
result: ToolResult{Stdout: nil, Stderr: []byte("error occurred\n"), ProcessState: cmdFail.ProcessState},
wantContains: []string{"▸ stdout:", "▸ stderr:", "error occurred", "┗ exit: 3"},
},
{
name: "both stdout and stderr",
result: ToolResult{Stdout: []byte("output"), Stderr: []byte("warning"), ProcessState: cmdOK.ProcessState},
wantContains: []string{"▸ stdout:", "output", "▸ stderr:", "warning", "┗ exit: 0"},
},
{
name: "empty output",
result: ToolResult{Stdout: nil, Stderr: nil, ProcessState: cmdOK.ProcessState},
wantContains: []string{"▸ stdout:", "▸ stderr:", "┗ exit: 0"},
},
{
name: "binary stdout (PNG)",
result: ToolResult{Stdout: []byte("\x89PNG\r\n\x1a\n"), Stderr: nil, ProcessState: cmdOK.ProcessState},
wantContains: []string{"▸ stdout:", binaryOutputPrefix, "image/png", "▸ stderr:", "┗ exit: 0"},
},
{
name: "binary stderr",
result: ToolResult{Stdout: nil, Stderr: []byte("\x89PNG\r\n\x1a\n"), ProcessState: cmdOK.ProcessState},
wantContains: []string{"▸ stdout:", "▸ stderr:", binaryOutputPrefix, "image/png", "┗ exit: 0"},
},
{
name: "HTML",
result: ToolResult{Stdout: []byte("<HTML><BODY>Hello</BODY></HTML>\n"), Stderr: nil, ProcessState: cmdOK.ProcessState},
wantContains: []string{"▸ stdout:", "<HTML>", "▸ stderr:", "┗ exit: 0"},
},
{
name: "XML",
result: ToolResult{Stdout: []byte("<?xml version=\"1.0\"?><root/>\n"), Stderr: nil, ProcessState: cmdOK.ProcessState},
wantContains: []string{"▸ stdout:", "<?xml", "▸ stderr:", "┗ exit: 0"},
},
{
name: "Go source",
result: ToolResult{Stdout: []byte("package main\n\nfunc main() {}\n"), Stderr: nil, ProcessState: cmdOK.ProcessState},
wantContains: []string{"▸ stdout:", "package main", "▸ stderr:", "┗ exit: 0"},
},
{
name: "JSON",
result: ToolResult{Stdout: []byte(`{"key":"value"}`), Stderr: nil, ProcessState: cmdOK.ProcessState},
wantContains: []string{"▸ stdout:", `{"key":"value"}`, "▸ stderr:", "┗ exit: 0"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.result.FormatResult()
for _, want := range tt.wantContains {
if !strings.Contains(got, want) {
t.Errorf("FormatResult() = %q, want to contain %q", got, want)
}
}
})
}
}
func TestRegistry_Execute_Success(t *testing.T) {
reg, err := NewRegistry([]config.ToolConfig{
{Name: "echo", Description: "echo", Command: "echo", Arguments: []string{"hello"}},
})
if err != nil {
t.Fatalf("NewRegistry: %v", err)
}
result, err := reg.Execute(context.Background(), "echo", "{}")
if err != nil {
t.Fatalf("Execute: %v", err)
}
if result.ProcessState.ExitCode() != 0 {
t.Errorf("ExitCode = %d, want 0", result.ProcessState.ExitCode())
}
if !bytes.Contains(result.Stdout, []byte("hello")) {
t.Errorf("Stdout = %q, want to contain hello", result.Stdout)
}
}
func TestRegistry_Execute_NonZeroExit(t *testing.T) {
reg, err := NewRegistry([]config.ToolConfig{
{Name: "fail", Description: "fail", Command: "sh", Arguments: []string{"-c", "exit 42"}},
})
if err != nil {
t.Fatalf("NewRegistry: %v", err)
}
result, err := reg.Execute(context.Background(), "fail", "{}")
if err != nil {
t.Fatalf("Execute: %v", err)
}
if result.ProcessState.ExitCode() != 42 {
t.Errorf("ExitCode = %d, want 42", result.ProcessState.ExitCode())
}
}
func TestRegistry_Execute_UnknownCommand(t *testing.T) {
reg, err := NewRegistry([]config.ToolConfig{
{Name: "nope", Description: "nope", Command: "nonexistent_binary_xyz", Arguments: []string{}},
})
if err != nil {
t.Fatalf("NewRegistry: %v", err)
}
_, err = reg.Execute(context.Background(), "nope", "{}")
if err == nil {
t.Fatal("Execute: want error, got nil")
}
}
func TestRegistry_Execute_UnknownTool(t *testing.T) {
reg, err := NewRegistry([]config.ToolConfig{})
if err != nil {
t.Fatalf("NewRegistry: %v", err)
}
_, err = reg.Execute(context.Background(), "missing", "{}")
if err == nil {
t.Fatal("Execute: want error, got nil")
}
if !strings.Contains(err.Error(), "unknown tool") {
t.Errorf("err = %v, want unknown tool error", err)
}
}
func TestRegistry_Execute_Timeout(t *testing.T) {
reg, err := NewRegistry([]config.ToolConfig{
{Name: "sleep", Description: "sleep", Command: "sleep", Arguments: []string{"10"}, Timeout: "50ms"},
})
if err != nil {
t.Fatalf("NewRegistry: %v", err)
}
_, err = reg.Execute(context.Background(), "sleep", "{}")
if err == nil {
t.Fatal("Execute: want timeout error, got nil")
}
if !strings.Contains(err.Error(), "timed out") {
t.Errorf("err = %v, want timed out error", err)
}
}
func TestRegistry_Execute_SignalKilled(t *testing.T) {
reg, err := NewRegistry([]config.ToolConfig{
{Name: "kill", Description: "kill", Command: "sh", Arguments: []string{"-c", "sleep 100 & kill -9 $!; wait"}},
})
if err != nil {
t.Fatalf("NewRegistry: %v", err)
}
result, err := reg.Execute(context.Background(), "kill", "{}")
if err != nil {
t.Fatalf("Execute: %v", err)
}
// The process we kill exits with signal, but the shell itself may exit 0.
// On some systems the exit code is 137 (128+9). Check ProcessState for signal info.
if result.ProcessState.ExitCode() == -1 {
if !strings.Contains(result.ProcessState.String(), "signal:") {
t.Errorf("ProcessState.String() = %q, want signal info", result.ProcessState.String())
}
}
}