Initial commit
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
package openapi
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
APIKey string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
func NewClient(baseURL, apiKey string) *Client {
|
||||
if !strings.HasSuffix(baseURL, "/") {
|
||||
baseURL += "/"
|
||||
}
|
||||
return &Client{
|
||||
BaseURL: baseURL,
|
||||
APIKey: apiKey,
|
||||
HTTPClient: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) CreateChatCompletion(ctx context.Context, req ChatCompletionRequest) (*ChatCompletionResponse, error) {
|
||||
req.Stream = false
|
||||
data, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL+"chat/completions", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
c.setHeaders(httpReq)
|
||||
|
||||
resp, err := c.HTTPClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("do request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result ChatCompletionResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (c *Client) CreateChatCompletionStream(ctx context.Context, req ChatCompletionRequest) (<-chan ChatCompletionChunk, <-chan error) {
|
||||
req.Stream = true
|
||||
chunks := make(chan ChatCompletionChunk)
|
||||
errs := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
defer close(chunks)
|
||||
defer close(errs)
|
||||
|
||||
data, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
errs <- fmt.Errorf("marshal request: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL+"chat/completions", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
errs <- fmt.Errorf("create request: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.setHeaders(httpReq)
|
||||
|
||||
resp, err := c.HTTPClient.Do(httpReq)
|
||||
if err != nil {
|
||||
errs <- fmt.Errorf("do request: %w", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
errs <- fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
|
||||
return
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
if data == "[DONE]" {
|
||||
break
|
||||
}
|
||||
|
||||
var chunk ChatCompletionChunk
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
errs <- fmt.Errorf("unmarshal chunk: %w", err)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case chunks <- chunk:
|
||||
case <-ctx.Done():
|
||||
errs <- ctx.Err()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
errs <- fmt.Errorf("scanner error: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return chunks, errs
|
||||
}
|
||||
|
||||
func (c *Client) setHeaders(req *http.Request) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.APIKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package openapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreateChatCompletion(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/chat/completions" {
|
||||
t.Errorf("expected path /chat/completions, got %s", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer test-key" {
|
||||
t.Errorf("expected bearer token, got %s", r.Header.Get("Authorization"))
|
||||
}
|
||||
|
||||
var req ChatCompletionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !req.CachePrompt {
|
||||
t.Error("expected cache_prompt to be true")
|
||||
}
|
||||
|
||||
resp := ChatCompletionResponse{
|
||||
ID: "test-id",
|
||||
Model: "test-model",
|
||||
Choices: []Choice{
|
||||
{
|
||||
Message: Message{
|
||||
Role: RoleAssistant,
|
||||
Content: "Hello!",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-key")
|
||||
resp, err := client.CreateChatCompletion(context.Background(), ChatCompletionRequest{
|
||||
Model: "test-model",
|
||||
Messages: []Message{
|
||||
{Role: RoleUser, Content: "Hi"},
|
||||
},
|
||||
CachePrompt: true,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if resp.Choices[0].Message.Content != "Hello!" {
|
||||
t.Errorf("expected Hello!, got %s", resp.Choices[0].Message.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateChatCompletionWithReasoning(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := ChatCompletionResponse{
|
||||
Choices: []Choice{
|
||||
{
|
||||
Message: Message{
|
||||
Role: RoleAssistant,
|
||||
Content: "Hello!",
|
||||
ReasoningContent: "Thinking...",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-key")
|
||||
resp, err := client.CreateChatCompletion(context.Background(), ChatCompletionRequest{
|
||||
Model: "test-model",
|
||||
Messages: []Message{
|
||||
{Role: RoleUser, Content: "Hi"},
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if resp.Choices[0].Message.ReasoningContent != "Thinking..." {
|
||||
t.Errorf("expected Thinking..., got %s", resp.Choices[0].Message.ReasoningContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateChatCompletionWithToolCalls(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := ChatCompletionResponse{
|
||||
Choices: []Choice{
|
||||
{
|
||||
Message: Message{
|
||||
Role: RoleAssistant,
|
||||
ToolCalls: []ToolCall{
|
||||
{
|
||||
ID: "call_123",
|
||||
Type: "function",
|
||||
Function: ToolCallFunction{
|
||||
Name: "get_weather",
|
||||
Arguments: `{"location":"Stockholm"}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-key")
|
||||
resp, err := client.CreateChatCompletion(context.Background(), ChatCompletionRequest{
|
||||
Model: "test-model",
|
||||
Messages: []Message{
|
||||
{Role: RoleUser, Content: "What is the weather in Stockholm?"},
|
||||
},
|
||||
Tools: []Tool{
|
||||
{
|
||||
Type: "function",
|
||||
Function: FunctionDefinition{
|
||||
Name: "get_weather",
|
||||
Description: "Get the current weather",
|
||||
Parameters: json.RawMessage(`{"type":"object","properties":{"location":{"type":"string"}}}`),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(resp.Choices[0].Message.ToolCalls) != 1 {
|
||||
t.Fatalf("expected 1 tool call, got %d", len(resp.Choices[0].Message.ToolCalls))
|
||||
}
|
||||
|
||||
tc := resp.Choices[0].Message.ToolCalls[0]
|
||||
if tc.Function.Name != "get_weather" {
|
||||
t.Errorf("expected get_weather, got %s", tc.Function.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateChatCompletionWithToolResponse(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req ChatCompletionRequest
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
// Check if the tool response is present
|
||||
foundToolResponse := false
|
||||
for _, msg := range req.Messages {
|
||||
if msg.Role == RoleTool && msg.ToolCallID == "call_123" {
|
||||
foundToolResponse = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !foundToolResponse {
|
||||
t.Error("expected tool response message in request")
|
||||
}
|
||||
|
||||
resp := ChatCompletionResponse{
|
||||
Choices: []Choice{
|
||||
{
|
||||
Message: Message{
|
||||
Role: RoleAssistant,
|
||||
Content: "The weather in Stockholm is 15°C.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-key")
|
||||
_, err := client.CreateChatCompletion(context.Background(), ChatCompletionRequest{
|
||||
Model: "test-model",
|
||||
Messages: []Message{
|
||||
{Role: RoleUser, Content: "What is the weather in Stockholm?"},
|
||||
{
|
||||
Role: RoleAssistant,
|
||||
ToolCalls: []ToolCall{
|
||||
{
|
||||
ID: "call_123",
|
||||
Type: "function",
|
||||
Function: ToolCallFunction{
|
||||
Name: "get_weather",
|
||||
Arguments: `{"location":"Stockholm"}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: RoleTool,
|
||||
ToolCallID: "call_123",
|
||||
Content: `{"temp":15}`,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateChatCompletionStreamWithReasoning(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
fmt.Fprintf(w, "data: %s\n\n", `{"choices":[{"delta":{"reasoning_content":"Thinking"}}]} `)
|
||||
fmt.Fprintf(w, "data: %s\n\n", `{"choices":[{"delta":{"content":"Hello"}}]} `)
|
||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-key")
|
||||
chunks, errs := client.CreateChatCompletionStream(context.Background(), ChatCompletionRequest{
|
||||
Model: "test-model",
|
||||
Messages: []Message{
|
||||
{Role: RoleUser, Content: "Hi"},
|
||||
},
|
||||
})
|
||||
|
||||
var reasoning, content string
|
||||
for chunk := range chunks {
|
||||
if len(chunk.Choices) > 0 {
|
||||
reasoning += chunk.Choices[0].Delta.ReasoningContent
|
||||
content += chunk.Choices[0].Delta.Content
|
||||
}
|
||||
}
|
||||
|
||||
if err := <-errs; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if reasoning != "Thinking" {
|
||||
t.Errorf("expected Thinking, got %s", reasoning)
|
||||
}
|
||||
if content != "Hello" {
|
||||
t.Errorf("expected Hello, got %s", content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package openapi
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DiffBlock represents a single file change in a git diff.
|
||||
type DiffBlock struct {
|
||||
File string
|
||||
Content string // The full diff content for this file
|
||||
}
|
||||
|
||||
// DiffParser parses a stream of git diff output.
|
||||
type DiffParser struct {
|
||||
onBlock func(DiffBlock)
|
||||
current *DiffBlock
|
||||
sb strings.Builder
|
||||
}
|
||||
|
||||
// NewDiffParser creates a new DiffParser.
|
||||
func NewDiffParser(onBlock func(DiffBlock)) *DiffParser {
|
||||
return &DiffParser{onBlock: onBlock}
|
||||
}
|
||||
|
||||
// Parse reads from r and calls onBlock for each completed diff block.
|
||||
func (p *DiffParser) Parse(r io.Reader) error {
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
p.Feed(scanner.Text())
|
||||
}
|
||||
p.Flush()
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
// Feed processes a single line of diff output.
|
||||
func (p *DiffParser) Feed(line string) {
|
||||
if strings.HasPrefix(line, "diff --git ") {
|
||||
p.Flush()
|
||||
p.current = &DiffBlock{}
|
||||
// Extract file path from "diff --git a/path/to/file b/path/to/file"
|
||||
parts := strings.Split(line, " ")
|
||||
if len(parts) >= 4 {
|
||||
p.current.File = strings.TrimPrefix(parts[3], "b/")
|
||||
}
|
||||
}
|
||||
|
||||
if p.current != nil {
|
||||
p.sb.WriteString(line)
|
||||
p.sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Flush finalizes the current block and emits it.
|
||||
func (p *DiffParser) Flush() {
|
||||
if p.current != nil {
|
||||
p.current.Content = p.sb.String()
|
||||
p.onBlock(*p.current)
|
||||
p.current = nil
|
||||
p.sb.Reset()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package openapi
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDiffParser(t *testing.T) {
|
||||
input := `diff --git a/pkg/openapi/session.go b/pkg/openapi/session.go
|
||||
index 1234567..89abcdef 100644
|
||||
--- a/pkg/openapi/session.go
|
||||
+++ b/pkg/openapi/session.go
|
||||
@@ -1,3 +1,4 @@
|
||||
package openapi
|
||||
|
||||
import (
|
||||
+ "os"
|
||||
diff --git a/main.go b/main.go
|
||||
index 0000000..1111111
|
||||
--- a/main.go
|
||||
+++ b/main.go
|
||||
@@ -10,1 +10,1 @@
|
||||
-old
|
||||
+new
|
||||
`
|
||||
|
||||
var blocks []DiffBlock
|
||||
parser := NewDiffParser(func(b DiffBlock) {
|
||||
blocks = append(blocks, b)
|
||||
})
|
||||
|
||||
err := parser.Parse(strings.NewReader(input))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed: %v", err)
|
||||
}
|
||||
|
||||
if len(blocks) != 2 {
|
||||
t.Errorf("Expected 2 blocks, got %d", len(blocks))
|
||||
}
|
||||
|
||||
if blocks[0].File != "pkg/openapi/session.go" {
|
||||
t.Errorf("Expected first file pkg/openapi/session.go, got %s", blocks[0].File)
|
||||
}
|
||||
|
||||
if blocks[1].File != "main.go" {
|
||||
t.Errorf("Expected second file main.go, got %s", blocks[1].File)
|
||||
}
|
||||
|
||||
if !strings.Contains(blocks[1].Content, "+new") {
|
||||
t.Errorf("Expected block 1 to contain '+new', got %s", blocks[1].Content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package openapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionHooks provides hooks into the session lifecycle.
|
||||
type SessionHooks struct {
|
||||
BeforeMessage func(ctx context.Context, s *Session, msg *Message) error
|
||||
AfterMessage func(ctx context.Context, s *Session, msg *Message) error
|
||||
BeforeToolCall func(ctx context.Context, s *Session, tc *ToolCall) error
|
||||
AfterToolCall func(ctx context.Context, s *Session, tc *ToolCall, result any, err error) error
|
||||
OnReasoning func(ctx context.Context, s *Session, reasoning string) error
|
||||
OnDone func(ctx context.Context, s *Session) error
|
||||
OnError func(ctx context.Context, s *Session, err error)
|
||||
}
|
||||
|
||||
// Session manages a conversation history and tool execution logic.
|
||||
type Session struct {
|
||||
client *Client
|
||||
model string
|
||||
messages []Message
|
||||
registry ToolRegistry
|
||||
hooks SessionHooks
|
||||
|
||||
// TurnTimeout is the timeout for a single LLM request.
|
||||
TurnTimeout time.Duration
|
||||
|
||||
// DefaultRequestOptions allows setting default parameters for all requests in the session.
|
||||
DefaultRequestOptions ChatCompletionRequest
|
||||
}
|
||||
|
||||
// NewSession creates a new session with the given client and model.
|
||||
func NewSession(client *Client, model string) *Session {
|
||||
return &Session{
|
||||
client: client,
|
||||
model: model,
|
||||
registry: NewMapRegistry(),
|
||||
TurnTimeout: 2 * time.Minute,
|
||||
DefaultRequestOptions: ChatCompletionRequest{
|
||||
CachePrompt: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SetRegistry replaces the tool registry.
|
||||
func (s *Session) SetRegistry(r ToolRegistry) {
|
||||
s.registry = r
|
||||
}
|
||||
|
||||
// RegisterTool is a helper for the default MapRegistry.
|
||||
func (s *Session) RegisterTool(name, description string, parameters any, handler ToolHandler) error {
|
||||
if mr, ok := s.registry.(*MapRegistry); ok {
|
||||
return mr.Register(name, description, parameters, handler)
|
||||
}
|
||||
return fmt.Errorf("current registry does not support direct registration")
|
||||
}
|
||||
|
||||
// AddMessage adds a message to the conversation history, triggering hooks.
|
||||
func (s *Session) AddMessage(ctx context.Context, msg Message) error {
|
||||
if s.hooks.BeforeMessage != nil {
|
||||
if err := s.hooks.BeforeMessage(ctx, s, &msg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.messages = append(s.messages, msg)
|
||||
if s.hooks.AfterMessage != nil {
|
||||
return s.hooks.AfterMessage(ctx, s, &msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSystemPrompt sets the initial system prompt.
|
||||
func (s *Session) SetSystemPrompt(prompt string) {
|
||||
if len(s.messages) > 0 && s.messages[0].Role == RoleSystem {
|
||||
s.messages[0].Content = prompt
|
||||
} else {
|
||||
s.messages = append([]Message{{Role: RoleSystem, Content: prompt}}, s.messages...)
|
||||
}
|
||||
}
|
||||
|
||||
// Chat sends a user prompt and handles the response loop asynchronously.
|
||||
func (s *Session) Chat(ctx context.Context, userPrompt string) (<-chan ChatCompletionChunk, <-chan error) {
|
||||
chunks := make(chan ChatCompletionChunk)
|
||||
errs := make(chan error, 1)
|
||||
go s.runLoop(ctx, userPrompt, chunks, errs)
|
||||
return chunks, errs
|
||||
}
|
||||
|
||||
func (s *Session) runLoop(ctx context.Context, userPrompt string, chunks chan<- ChatCompletionChunk, errs chan<- error) {
|
||||
defer close(chunks)
|
||||
defer close(errs)
|
||||
|
||||
if userPrompt != "" {
|
||||
if err := s.AddMessage(ctx, Message{Role: RoleUser, Content: userPrompt}); err != nil {
|
||||
s.handleError(ctx, err, errs)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
resp, err := s.nextCompletion(ctx)
|
||||
if err != nil {
|
||||
s.handleError(ctx, err, errs)
|
||||
return
|
||||
}
|
||||
|
||||
choice := resp.Choices[0]
|
||||
if err := s.processResponse(ctx, choice.Message); err != nil {
|
||||
s.handleError(ctx, err, errs)
|
||||
return
|
||||
}
|
||||
|
||||
if len(choice.Message.ToolCalls) > 0 {
|
||||
if err := s.handleToolCalls(ctx, choice.Message.ToolCalls); err != nil {
|
||||
s.handleError(ctx, err, errs)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if s.hooks.OnDone != nil {
|
||||
s.hooks.OnDone(ctx, s)
|
||||
}
|
||||
chunks <- s.asChunk(resp, choice)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) nextCompletion(ctx context.Context) (*ChatCompletionResponse, error) {
|
||||
turnCtx, cancel := s.extendContext(ctx)
|
||||
defer cancel()
|
||||
|
||||
req := s.DefaultRequestOptions
|
||||
req.Model = s.model
|
||||
req.Messages = s.messages
|
||||
req.Tools = s.registry.Tools(turnCtx)
|
||||
return s.client.CreateChatCompletion(turnCtx, req)
|
||||
}
|
||||
|
||||
func (s *Session) processResponse(ctx context.Context, msg Message) error {
|
||||
if msg.ReasoningContent != "" && s.hooks.OnReasoning != nil {
|
||||
if err := s.hooks.OnReasoning(ctx, s, msg.ReasoningContent); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.AddMessage(ctx, msg)
|
||||
}
|
||||
|
||||
func (s *Session) handleToolCalls(ctx context.Context, toolCalls []ToolCall) error {
|
||||
for i := range toolCalls {
|
||||
tc := &toolCalls[i]
|
||||
if s.hooks.BeforeToolCall != nil {
|
||||
if err := s.hooks.BeforeToolCall(ctx, s, tc); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Use extended context for tool execution to prevent timeouts on long tasks
|
||||
toolCtx, cancel := s.extendContext(ctx)
|
||||
result, err := s.registry.Execute(toolCtx, s, tc.Function.Name, json.RawMessage(tc.Function.Arguments))
|
||||
cancel()
|
||||
|
||||
if s.hooks.AfterToolCall != nil {
|
||||
if hErr := s.hooks.AfterToolCall(ctx, s, tc, result, err); hErr != nil {
|
||||
return hErr
|
||||
}
|
||||
}
|
||||
|
||||
content := ""
|
||||
if err != nil {
|
||||
content = fmt.Sprintf("Error: %v", err)
|
||||
} else {
|
||||
resJSON, _ := json.Marshal(result)
|
||||
content = string(resJSON)
|
||||
}
|
||||
|
||||
if err := s.AddMessage(ctx, Message{
|
||||
Role: RoleTool,
|
||||
Name: tc.Function.Name,
|
||||
ToolCallID: tc.ID,
|
||||
Content: content,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extendContext creates a context that resets the timeout for a new "turn",
|
||||
// ignoring the parent's deadline but respecting its cancellation.
|
||||
func (s *Session) extendContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
extCtx, cancel := context.WithTimeout(context.Background(), s.TurnTimeout)
|
||||
|
||||
stop := make(chan struct{})
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
cancel()
|
||||
case <-stop:
|
||||
case <-extCtx.Done():
|
||||
}
|
||||
}()
|
||||
|
||||
return extCtx, func() {
|
||||
close(stop)
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) asChunk(resp *ChatCompletionResponse, choice Choice) ChatCompletionChunk {
|
||||
return ChatCompletionChunk{
|
||||
ID: resp.ID,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: resp.Created,
|
||||
Model: resp.Model,
|
||||
Choices: []struct {
|
||||
Index int `json:"index"`
|
||||
Delta Message `json:"delta"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
}{
|
||||
{
|
||||
Index: choice.Index,
|
||||
Delta: Message{
|
||||
Role: choice.Message.Role,
|
||||
Content: choice.Message.Content,
|
||||
ReasoningContent: choice.Message.ReasoningContent,
|
||||
},
|
||||
FinishReason: choice.FinishReason,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) handleError(ctx context.Context, err error, errs chan<- error) {
|
||||
if s.hooks.OnError != nil {
|
||||
s.hooks.OnError(ctx, s, err)
|
||||
}
|
||||
errs <- err
|
||||
}
|
||||
|
||||
func (s *Session) Messages() []Message { return s.messages }
|
||||
func (s *Session) SetHooks(h SessionHooks) { s.hooks = h }
|
||||
@@ -0,0 +1,135 @@
|
||||
package openapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSessionToolTurns(t *testing.T) {
|
||||
callCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
var resp ChatCompletionResponse
|
||||
|
||||
if callCount == 1 {
|
||||
// First call: Model decides to call a tool
|
||||
resp = ChatCompletionResponse{
|
||||
ID: "resp1",
|
||||
Choices: []Choice{
|
||||
{
|
||||
Message: Message{
|
||||
Role: RoleAssistant,
|
||||
ToolCalls: []ToolCall{
|
||||
{
|
||||
ID: "call_1",
|
||||
Type: "function",
|
||||
Function: ToolCallFunction{
|
||||
Name: "get_weather",
|
||||
Arguments: `{"location":"Stockholm"}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
FinishReason: "tool_calls",
|
||||
},
|
||||
},
|
||||
}
|
||||
} else {
|
||||
// Second call: Model gives final response after tool result
|
||||
resp = ChatCompletionResponse{
|
||||
ID: "resp2",
|
||||
Choices: []Choice{
|
||||
{
|
||||
Message: Message{
|
||||
Role: RoleAssistant,
|
||||
Content: "The weather in Stockholm is sunny.",
|
||||
},
|
||||
FinishReason: "stop",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-key")
|
||||
session := NewSession(client, "test-model")
|
||||
|
||||
type weatherArgs struct {
|
||||
Location string `json:"location"`
|
||||
}
|
||||
|
||||
toolExecuted := false
|
||||
session.RegisterTool("get_weather", "Get weather",
|
||||
map[string]any{"type": "object", "properties": map[string]any{"location": map[string]any{"type": "string"}}},
|
||||
func(ctx context.Context, s *Session, args json.RawMessage) (any, error) {
|
||||
var w weatherArgs
|
||||
json.Unmarshal(args, &w)
|
||||
if w.Location != "Stockholm" {
|
||||
t.Errorf("expected Stockholm, got %s", w.Location)
|
||||
}
|
||||
toolExecuted = true
|
||||
return map[string]any{"temp": 20, "condition": "sunny"}, nil
|
||||
})
|
||||
|
||||
hooksCalled := make(map[string]bool)
|
||||
session.SetHooks(SessionHooks{
|
||||
BeforeMessage: func(ctx context.Context, s *Session, msg *Message) error {
|
||||
hooksCalled["BeforeMessage"] = true
|
||||
return nil
|
||||
},
|
||||
AfterMessage: func(ctx context.Context, s *Session, msg *Message) error {
|
||||
hooksCalled["AfterMessage"] = true
|
||||
return nil
|
||||
},
|
||||
BeforeToolCall: func(ctx context.Context, s *Session, tc *ToolCall) error {
|
||||
hooksCalled["BeforeToolCall"] = true
|
||||
return nil
|
||||
},
|
||||
AfterToolCall: func(ctx context.Context, s *Session, tc *ToolCall, result any, err error) error {
|
||||
hooksCalled["AfterToolCall"] = true
|
||||
return nil
|
||||
},
|
||||
OnDone: func(ctx context.Context, s *Session) error {
|
||||
hooksCalled["OnDone"] = true
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
chunks, errs := session.Chat(ctx, "How is the weather in Stockholm?")
|
||||
|
||||
var finalContent string
|
||||
for chunk := range chunks {
|
||||
if len(chunk.Choices) > 0 {
|
||||
finalContent += chunk.Choices[0].Delta.Content
|
||||
}
|
||||
}
|
||||
|
||||
if err := <-errs; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !toolExecuted {
|
||||
t.Error("expected tool to be executed")
|
||||
}
|
||||
|
||||
hookNames := []string{"BeforeMessage", "AfterMessage", "BeforeToolCall", "AfterToolCall", "OnDone"}
|
||||
for _, name := range hookNames {
|
||||
if !hooksCalled[name] {
|
||||
t.Errorf("expected hook %s to be called", name)
|
||||
}
|
||||
}
|
||||
|
||||
if finalContent != "The weather in Stockholm is sunny." {
|
||||
t.Errorf("expected final response, got %s", finalContent)
|
||||
}
|
||||
|
||||
if len(session.Messages()) != 4 {
|
||||
t.Errorf("expected 4 messages in history, got %d", len(session.Messages()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package openapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RunCommandArgs defines the arguments for the run_command tool.
|
||||
type RunCommandArgs struct {
|
||||
Command string `json:"command" desc:"The command to run"`
|
||||
}
|
||||
|
||||
// NewShellRegistry returns a registry with shell-related tools.
|
||||
func NewShellRegistry() ToolRegistry {
|
||||
r := NewMapRegistry()
|
||||
|
||||
RegisterTool(r, "run_command", "Execute a shell command",
|
||||
func(ctx context.Context, s *Session, params RunCommandArgs) (any, error) {
|
||||
cmd := exec.CommandContext(ctx, "sh", "-c", params.Command)
|
||||
output, err := cmd.CombinedOutput()
|
||||
return map[string]any{
|
||||
"output": string(output),
|
||||
"error": fmt.Sprintf("%v", err),
|
||||
}, nil
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// ReadFileArgs defines the arguments for the read_file tool.
|
||||
type ReadFileArgs struct {
|
||||
Path string `json:"path" desc:"Path to the file"`
|
||||
Start int `json:"start,omitempty" desc:"Start line number"`
|
||||
Lines int `json:"lines,omitempty" desc:"number of lines to read, default 200"`
|
||||
}
|
||||
|
||||
// WriteFileArgs defines the arguments for the write_file tool.
|
||||
type WriteFileArgs struct {
|
||||
Path string `json:"path" desc:"Path to the file"`
|
||||
Content string `json:"content" desc:"Content to write"`
|
||||
}
|
||||
|
||||
// ListDirArgs defines the arguments for the list_dir tool.
|
||||
type ListDirArgs struct {
|
||||
Path string `json:"path" desc:"Path to the directory"`
|
||||
}
|
||||
|
||||
// ReplaceContentArgs defines the arguments for the replace_content tool.
|
||||
type ReplaceContentArgs struct {
|
||||
Path string `json:"path" desc:"Path to the file"`
|
||||
TargetContent string `json:"target_content" desc:"The exact content to replace"`
|
||||
NewContent string `json:"new_content" desc:"The new content"`
|
||||
}
|
||||
|
||||
// NewFileRegistry returns a registry with file-related tools.
|
||||
func NewFileRegistry(baseDir string) ToolRegistry {
|
||||
r := NewMapRegistry()
|
||||
|
||||
RegisterTool(r, "read_file", "Read the contents of a file",
|
||||
func(ctx context.Context, s *Session, params ReadFileArgs) (any, error) {
|
||||
fullPath := filepath.Join(baseDir, params.Path)
|
||||
content, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lines := strings.Split(string(content), "\n")
|
||||
if params.Start < 1 {
|
||||
params.Start = 1
|
||||
}
|
||||
if params.Lines == 0 {
|
||||
params.Lines = 200
|
||||
}
|
||||
|
||||
start := params.Start - 1
|
||||
end := start + params.Lines
|
||||
if end > len(lines) {
|
||||
end = len(lines)
|
||||
}
|
||||
|
||||
if start >= end {
|
||||
return nil, fmt.Errorf("invalid start or lines parameters")
|
||||
}
|
||||
|
||||
return strings.Join(lines[start:end], "\n"), nil
|
||||
})
|
||||
|
||||
RegisterTool(r, "write_file", "Write content to a file",
|
||||
func(ctx context.Context, s *Session, params WriteFileArgs) (any, error) {
|
||||
fullPath := filepath.Join(baseDir, params.Path)
|
||||
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(fullPath, []byte(params.Content), 0644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return "File written successfully", nil
|
||||
})
|
||||
|
||||
RegisterTool(r, "list_dir", "List contents of a directory",
|
||||
func(ctx context.Context, s *Session, params ListDirArgs) (any, error) {
|
||||
fullPath := filepath.Join(baseDir, params.Path)
|
||||
entries, err := os.ReadDir(fullPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []string
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if entry.IsDir() {
|
||||
name += "/"
|
||||
}
|
||||
result = append(result, name)
|
||||
}
|
||||
return result, nil
|
||||
})
|
||||
|
||||
RegisterTool(r, "replace_content", "Replace a block of text in a file",
|
||||
func(ctx context.Context, s *Session, params ReplaceContentArgs) (any, error) {
|
||||
fullPath := filepath.Join(baseDir, params.Path)
|
||||
content, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
oldContent := string(content)
|
||||
if !strings.Contains(oldContent, params.TargetContent) {
|
||||
return nil, fmt.Errorf("target content not found in file")
|
||||
}
|
||||
|
||||
newContent := strings.Replace(oldContent, params.TargetContent, params.NewContent, 1)
|
||||
if err := os.WriteFile(fullPath, []byte(newContent), 0644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return "Content replaced successfully", nil
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package openapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestShellRegistry(t *testing.T) {
|
||||
registry := NewShellRegistry()
|
||||
ctx := context.Background()
|
||||
session := &Session{}
|
||||
|
||||
args := json.RawMessage(`{"command": "echo 'hello world'"}`)
|
||||
result, err := registry.Execute(ctx, session, "run_command", args)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute failed: %v", err)
|
||||
}
|
||||
|
||||
resMap := result.(map[string]any)
|
||||
if resMap["output"] != "hello world\n" {
|
||||
t.Errorf("Expected 'hello world\n', got %q", resMap["output"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileRegistry(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "file-registry-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
registry := NewFileRegistry(tempDir)
|
||||
ctx := context.Background()
|
||||
session := &Session{}
|
||||
|
||||
// Test write_file
|
||||
writeArgs := json.RawMessage(`{"path": "test.txt", "content": "hello file"}`)
|
||||
_, err = registry.Execute(ctx, session, "write_file", writeArgs)
|
||||
if err != nil {
|
||||
t.Fatalf("write_file failed: %v", err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(tempDir, "test.txt"))
|
||||
if err != nil || string(content) != "hello file" {
|
||||
t.Errorf("File content mismatch: %q", string(content))
|
||||
}
|
||||
|
||||
// Test read_file
|
||||
readArgs := json.RawMessage(`{"path": "test.txt"}`)
|
||||
res, err := registry.Execute(ctx, session, "read_file", readArgs)
|
||||
if err != nil {
|
||||
t.Fatalf("read_file failed: %v", err)
|
||||
}
|
||||
if res.(string) != "hello file" {
|
||||
t.Errorf("read_file output mismatch: %q", res)
|
||||
}
|
||||
|
||||
// Test list_dir
|
||||
listArgs := json.RawMessage(`{"path": "."}`)
|
||||
res, err = registry.Execute(ctx, session, "list_dir", listArgs)
|
||||
if err != nil {
|
||||
t.Fatalf("list_dir failed: %v", err)
|
||||
}
|
||||
files := res.([]string)
|
||||
if len(files) != 1 || files[0] != "test.txt" {
|
||||
t.Errorf("list_dir output mismatch: %v", files)
|
||||
}
|
||||
|
||||
// Test replace_content
|
||||
replaceArgs := json.RawMessage(`{"path": "test.txt", "target_content": "file", "new_content": "replacement"}`)
|
||||
_, err = registry.Execute(ctx, session, "replace_content", replaceArgs)
|
||||
if err != nil {
|
||||
t.Fatalf("replace_content failed: %v", err)
|
||||
}
|
||||
|
||||
content, err = os.ReadFile(filepath.Join(tempDir, "test.txt"))
|
||||
if err != nil || string(content) != "hello replacement" {
|
||||
t.Errorf("File content mismatch after replacement: %q", string(content))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package openapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ReflectRegistry provides helper methods for registering tools using reflection.
|
||||
type ReflectRegistry struct {
|
||||
*MapRegistry
|
||||
}
|
||||
|
||||
// NewReflectRegistry creates a new ReflectRegistry wrapping a MapRegistry.
|
||||
func NewReflectRegistry(r *MapRegistry) *ReflectRegistry {
|
||||
return &ReflectRegistry{MapRegistry: r}
|
||||
}
|
||||
|
||||
// RegisterTool registers a tool with a typed handler.
|
||||
// The handler's third argument must be a struct that defines the tool's parameters.
|
||||
func RegisterTool[T any](r *MapRegistry, name, description string, handler func(context.Context, *Session, T) (any, error)) error {
|
||||
var arg T
|
||||
schema, err := generateSchema(reflect.TypeOf(arg))
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate schema: %w", err)
|
||||
}
|
||||
|
||||
wrappedHandler := func(ctx context.Context, s *Session, args json.RawMessage) (any, error) {
|
||||
var params T
|
||||
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal arguments: %w", err)
|
||||
}
|
||||
return handler(ctx, s, params)
|
||||
}
|
||||
|
||||
return r.Register(name, description, schema, wrappedHandler)
|
||||
}
|
||||
|
||||
func generateSchema(t reflect.Type) (map[string]any, error) {
|
||||
if t.Kind() == reflect.Ptr {
|
||||
t = t.Elem()
|
||||
}
|
||||
|
||||
if t.Kind() != reflect.Struct {
|
||||
return nil, fmt.Errorf("expected struct, got %s", t.Kind())
|
||||
}
|
||||
|
||||
properties := make(map[string]any)
|
||||
var required []string
|
||||
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
jsonTag := field.Tag.Get("json")
|
||||
if jsonTag == "" || jsonTag == "-" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Split json tag to handle omitempty etc.
|
||||
parts := strings.Split(jsonTag, ",")
|
||||
name := parts[0]
|
||||
|
||||
prop := make(map[string]any)
|
||||
|
||||
switch field.Type.Kind() {
|
||||
case reflect.String:
|
||||
prop["type"] = "string"
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
prop["type"] = "integer"
|
||||
case reflect.Float32, reflect.Float64:
|
||||
prop["type"] = "number"
|
||||
case reflect.Bool:
|
||||
prop["type"] = "boolean"
|
||||
case reflect.Slice:
|
||||
prop["type"] = "array"
|
||||
// Simple slice handling, could be improved
|
||||
itemType, _ := getJsonType(field.Type.Elem())
|
||||
prop["items"] = map[string]string{"type": itemType}
|
||||
case reflect.Struct:
|
||||
subSchema, err := generateSchema(field.Type)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prop = subSchema
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported field type: %s", field.Type.Kind())
|
||||
}
|
||||
|
||||
if desc := field.Tag.Get("desc"); desc != "" {
|
||||
prop["description"] = desc
|
||||
}
|
||||
|
||||
properties[name] = prop
|
||||
|
||||
// Determine if field is required.
|
||||
// Priority: 'required' tag > 'omitempty' in 'json' tag.
|
||||
isRequired := true
|
||||
for _, p := range parts[1:] {
|
||||
if p == "omitempty" {
|
||||
isRequired = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if reqTag := field.Tag.Get("required"); reqTag != "" {
|
||||
isRequired = (reqTag == "true")
|
||||
}
|
||||
|
||||
if isRequired {
|
||||
required = append(required, name)
|
||||
}
|
||||
}
|
||||
|
||||
schema := map[string]any{
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
}
|
||||
if len(required) > 0 {
|
||||
schema["required"] = required
|
||||
}
|
||||
|
||||
return schema, nil
|
||||
}
|
||||
|
||||
func getJsonType(t reflect.Type) (string, error) {
|
||||
switch t.Kind() {
|
||||
case reflect.String:
|
||||
return "string", nil
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return "integer", nil
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return "number", nil
|
||||
case reflect.Bool:
|
||||
return "boolean", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported item type: %s", t.Kind())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package openapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type TestArgs struct {
|
||||
Name string `json:"name" desc:"The name of the user"`
|
||||
Age int `json:"age" desc:"The age of the user"`
|
||||
Tags []string `json:"tags,omitempty" desc:"Optional tags"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func TestRegisterTool(t *testing.T) {
|
||||
registry := NewMapRegistry()
|
||||
|
||||
err := RegisterTool(registry, "test_tool", "A test tool", func(ctx context.Context, s *Session, args TestArgs) (any, error) {
|
||||
return args.Name, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("failed to register tool: %v", err)
|
||||
}
|
||||
|
||||
tools := registry.Tools(context.Background())
|
||||
if len(tools) != 1 {
|
||||
t.Fatalf("expected 1 tool, got %d", len(tools))
|
||||
}
|
||||
|
||||
tool := tools[0]
|
||||
if tool.Function.Name != "test_tool" {
|
||||
t.Errorf("expected tool name 'test_tool', got '%s'", tool.Function.Name)
|
||||
}
|
||||
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(tool.Function.Parameters, &schema); err != nil {
|
||||
t.Fatalf("failed to unmarshal parameters: %v", err)
|
||||
}
|
||||
|
||||
// Check schema
|
||||
if schema["type"] != "object" {
|
||||
t.Errorf("expected type 'object', got '%v'", schema["type"])
|
||||
}
|
||||
|
||||
properties := schema["properties"].(map[string]any)
|
||||
if _, ok := properties["name"]; !ok {
|
||||
t.Error("missing 'name' property")
|
||||
}
|
||||
if properties["name"].(map[string]any)["description"] != "The name of the user" {
|
||||
t.Errorf("wrong description for 'name': %v", properties["name"].(map[string]any)["description"])
|
||||
}
|
||||
|
||||
required := schema["required"].([]any)
|
||||
foundName := false
|
||||
for _, r := range required {
|
||||
if r == "name" {
|
||||
foundName = true
|
||||
}
|
||||
}
|
||||
if !foundName {
|
||||
t.Error("'name' should be required")
|
||||
}
|
||||
|
||||
// Test execution
|
||||
args := json.RawMessage(`{"name": "Alice", "age": 30, "enabled": true}`)
|
||||
res, err := registry.Execute(context.Background(), nil, "test_tool", args)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to execute tool: %v", err)
|
||||
}
|
||||
if res != "Alice" {
|
||||
t.Errorf("expected result 'Alice', got '%v'", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequiredTag(t *testing.T) {
|
||||
type RequiredArgs struct {
|
||||
ExplicitTrue string `json:"explicit_true,omitempty" required:"true"`
|
||||
ExplicitFalse string `json:"explicit_false" required:"false"`
|
||||
DefaultTrue string `json:"default_true"`
|
||||
DefaultFalse string `json:"default_false,omitempty"`
|
||||
}
|
||||
|
||||
registry := NewMapRegistry()
|
||||
err := RegisterTool(registry, "req_tool", "A tool with required tags", func(ctx context.Context, s *Session, args RequiredArgs) (any, error) {
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to register tool: %v", err)
|
||||
}
|
||||
|
||||
tools := registry.Tools(context.Background())
|
||||
tool := tools[0]
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(tool.Function.Parameters, &schema); err != nil {
|
||||
t.Fatalf("failed to unmarshal parameters: %v", err)
|
||||
}
|
||||
|
||||
required := schema["required"].([]any)
|
||||
reqMap := make(map[string]bool)
|
||||
for _, r := range required {
|
||||
reqMap[r.(string)] = true
|
||||
}
|
||||
|
||||
if !reqMap["explicit_true"] {
|
||||
t.Error("expected 'explicit_true' to be required")
|
||||
}
|
||||
if reqMap["explicit_false"] {
|
||||
t.Error("expected 'explicit_false' to NOT be required")
|
||||
}
|
||||
if !reqMap["default_true"] {
|
||||
t.Error("expected 'default_true' to be required")
|
||||
}
|
||||
if reqMap["default_false"] {
|
||||
t.Error("expected 'default_false' to NOT be required")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package openapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ToolHandler is a function that implements a tool's logic.
|
||||
type ToolHandler func(ctx context.Context, s *Session, args json.RawMessage) (any, error)
|
||||
|
||||
// ToolDefinition pairs the OpenAI-compatible tool schema with a Go handler.
|
||||
type ToolDefinition struct {
|
||||
Tool Tool
|
||||
Handler ToolHandler
|
||||
}
|
||||
|
||||
// ToolRegistry defines the interface for tool discovery and execution.
|
||||
type ToolRegistry interface {
|
||||
Tools(ctx context.Context) []Tool
|
||||
Execute(ctx context.Context, s *Session, name string, args json.RawMessage) (any, error)
|
||||
}
|
||||
|
||||
// MapRegistry is a simple map-based implementation of ToolRegistry.
|
||||
type MapRegistry struct {
|
||||
tools map[string]ToolDefinition
|
||||
}
|
||||
|
||||
// NewMapRegistry creates a new MapRegistry.
|
||||
func NewMapRegistry() *MapRegistry {
|
||||
return &MapRegistry{tools: make(map[string]ToolDefinition)}
|
||||
}
|
||||
|
||||
// Register adds a tool to the registry.
|
||||
func (r *MapRegistry) Register(name, description string, parameters any, handler ToolHandler) error {
|
||||
paramsJSON, err := json.Marshal(parameters)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal parameters: %w", err)
|
||||
}
|
||||
|
||||
r.tools[name] = ToolDefinition{
|
||||
Tool: Tool{
|
||||
Type: "function",
|
||||
Function: FunctionDefinition{
|
||||
Name: name,
|
||||
Description: description,
|
||||
Parameters: paramsJSON,
|
||||
},
|
||||
},
|
||||
Handler: handler,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tools returns the list of tools in the registry.
|
||||
func (r *MapRegistry) Tools(ctx context.Context) []Tool {
|
||||
tools := make([]Tool, 0, len(r.tools))
|
||||
for _, td := range r.tools {
|
||||
tools = append(tools, td.Tool)
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
// Execute looks up and runs a tool by name.
|
||||
func (r *MapRegistry) Execute(ctx context.Context, s *Session, name string, args json.RawMessage) (any, error) {
|
||||
td, ok := r.tools[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("tool %s not found", name)
|
||||
}
|
||||
return td.Handler(ctx, s, args)
|
||||
}
|
||||
|
||||
// CompositeRegistry allows combining multiple tool registries.
|
||||
type CompositeRegistry struct {
|
||||
registries []ToolRegistry
|
||||
}
|
||||
|
||||
// Add adds a registry to the composite.
|
||||
func (r *CompositeRegistry) Add(registry ToolRegistry) {
|
||||
r.registries = append(r.registries, registry)
|
||||
}
|
||||
|
||||
// Tools returns the union of all tools from all registries.
|
||||
func (r *CompositeRegistry) Tools(ctx context.Context) []Tool {
|
||||
var all []Tool
|
||||
for _, reg := range r.registries {
|
||||
all = append(all, reg.Tools(ctx)...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// Execute attempts to execute the tool by trying each registry in order.
|
||||
func (r *CompositeRegistry) Execute(ctx context.Context, s *Session, name string, args json.RawMessage) (any, error) {
|
||||
for _, reg := range r.registries {
|
||||
res, err := reg.Execute(ctx, s, name, args)
|
||||
if err == nil {
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("tool %s not found in any registry", name)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package openapi
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleSystem Role = "system"
|
||||
RoleUser Role = "user"
|
||||
RoleAssistant Role = "assistant"
|
||||
RoleTool Role = "tool"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
Role Role `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
}
|
||||
|
||||
type ToolCall struct {
|
||||
Index *int `json:"index,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Function ToolCallFunction `json:"function,omitempty"`
|
||||
}
|
||||
|
||||
type ToolCallFunction struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Arguments string `json:"arguments,omitempty"`
|
||||
}
|
||||
|
||||
type Tool struct {
|
||||
Type string `json:"type"`
|
||||
Function FunctionDefinition `json:"function"`
|
||||
}
|
||||
|
||||
type FunctionDefinition struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Parameters json.RawMessage `json:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
type ChatCompletionRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
Tools []Tool `json:"tools,omitempty"`
|
||||
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP *float64 `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"`
|
||||
PresencePenalty *float64 `json:"presence_penalty,omitempty"`
|
||||
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"`
|
||||
LogitBias map[string]int `json:"logit_bias,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
|
||||
// llama.cpp specific
|
||||
CachePrompt bool `json:"cache_prompt,omitempty"`
|
||||
NKeep int `json:"n_keep,omitempty"`
|
||||
Seed int `json:"seed,omitempty"`
|
||||
}
|
||||
|
||||
type Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
}
|
||||
|
||||
type Choice struct {
|
||||
Index int `json:"index"`
|
||||
Message Message `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
}
|
||||
|
||||
type ChatCompletionResponse struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []Choice `json:"choices"`
|
||||
Usage Usage `json:"usage"`
|
||||
}
|
||||
|
||||
type ChatCompletionChunk struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []struct {
|
||||
Index int `json:"index"`
|
||||
Delta Message `json:"delta"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
Reference in New Issue
Block a user