Add go-llm v2: redesigned API for simpler LLM abstraction

v2 is a new Go module (v2/) with a dramatically simpler API:
- Unified Message type (no more Input marker interface)
- Define[T] for ergonomic tool creation with standard context.Context
- Chat session with automatic tool-call loop (agent loop)
- Streaming via pull-based StreamReader
- MCP one-call connect (MCPStdioServer, MCPHTTPServer, MCPSSEServer)
- Middleware support (logging, retry, timeout, usage tracking)
- Decoupled JSON Schema (map[string]any, no provider coupling)
- Sample tools: WebSearch, Browser, Exec, ReadFile, WriteFile, HTTP
- Providers: OpenAI, Anthropic, Google (all with streaming)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 20:00:08 -05:00
parent 85a848d96e
commit a4cb4baab5
28 changed files with 3598 additions and 0 deletions

59
v2/tools/browser.go Normal file
View File

@@ -0,0 +1,59 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
llm "gitea.stevedudenhoeffer.com/steve/go-llm/v2"
)
// BrowserParams defines parameters for the browser tool.
type BrowserParams struct {
URL string `json:"url" description:"The URL to fetch and extract text from"`
}
// Browser creates a simple web content fetcher tool.
// It fetches a URL and returns the text content.
//
// For a full headless browser, consider using an MCP server like Playwright MCP.
//
// Example:
//
// tools := llm.NewToolBox(tools.Browser())
func Browser() llm.Tool {
return llm.Define[BrowserParams]("browser", "Fetch a web page and return its text content",
func(ctx context.Context, p BrowserParams) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.URL, nil)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
req.Header.Set("User-Agent", "go-llm/2.0 (Web Fetcher)")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("fetching URL: %w", err)
}
defer resp.Body.Close()
// Limit to 1MB
limited := io.LimitReader(resp.Body, 1<<20)
body, err := io.ReadAll(limited)
if err != nil {
return "", fmt.Errorf("reading body: %w", err)
}
result := map[string]any{
"url": p.URL,
"status": resp.StatusCode,
"content_type": resp.Header.Get("Content-Type"),
"body": string(body),
}
out, _ := json.MarshalIndent(result, "", " ")
return string(out), nil
},
)
}

101
v2/tools/exec.go Normal file
View File

@@ -0,0 +1,101 @@
package tools
import (
"context"
"fmt"
"os/exec"
"runtime"
"strings"
"time"
llm "gitea.stevedudenhoeffer.com/steve/go-llm/v2"
)
// ExecParams defines parameters for the exec tool.
type ExecParams struct {
Command string `json:"command" description:"The shell command to execute"`
}
// ExecOption configures the exec tool.
type ExecOption func(*execConfig)
type execConfig struct {
allowedCommands []string
workDir string
timeout time.Duration
}
// WithAllowedCommands restricts which commands can be executed.
// If empty, all commands are allowed.
func WithAllowedCommands(cmds []string) ExecOption {
return func(c *execConfig) { c.allowedCommands = cmds }
}
// WithWorkDir sets the working directory for command execution.
func WithWorkDir(dir string) ExecOption {
return func(c *execConfig) { c.workDir = dir }
}
// WithExecTimeout sets the maximum execution time.
func WithExecTimeout(d time.Duration) ExecOption {
return func(c *execConfig) { c.timeout = d }
}
// Exec creates a shell command execution tool.
//
// Example:
//
// tools := llm.NewToolBox(
// tools.Exec(tools.WithAllowedCommands([]string{"ls", "cat", "grep"})),
// )
func Exec(opts ...ExecOption) llm.Tool {
cfg := &execConfig{
timeout: 30 * time.Second,
}
for _, opt := range opts {
opt(cfg)
}
return llm.Define[ExecParams]("exec", "Execute a shell command and return its output",
func(ctx context.Context, p ExecParams) (string, error) {
// Check allowed commands
if len(cfg.allowedCommands) > 0 {
parts := strings.Fields(p.Command)
if len(parts) == 0 {
return "", fmt.Errorf("empty command")
}
allowed := false
for _, cmd := range cfg.allowedCommands {
if parts[0] == cmd {
allowed = true
break
}
}
if !allowed {
return "", fmt.Errorf("command %q is not in the allowed list", parts[0])
}
}
ctx, cancel := context.WithTimeout(ctx, cfg.timeout)
defer cancel()
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
cmd = exec.CommandContext(ctx, "cmd", "/C", p.Command)
} else {
cmd = exec.CommandContext(ctx, "sh", "-c", p.Command)
}
if cfg.workDir != "" {
cmd.Dir = cfg.workDir
}
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Sprintf("Error: %s\nOutput: %s", err.Error(), string(output)), nil
}
return string(output), nil
},
)
}

75
v2/tools/http.go Normal file
View File

@@ -0,0 +1,75 @@
package tools
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
llm "gitea.stevedudenhoeffer.com/steve/go-llm/v2"
)
// HTTPParams defines parameters for the HTTP request tool.
type HTTPParams struct {
Method string `json:"method" description:"HTTP method" enum:"GET,POST,PUT,DELETE,PATCH,HEAD"`
URL string `json:"url" description:"Request URL"`
Headers map[string]string `json:"headers,omitempty" description:"Request headers"`
Body *string `json:"body,omitempty" description:"Request body"`
}
// HTTP creates an HTTP request tool.
//
// Example:
//
// tools := llm.NewToolBox(tools.HTTP())
func HTTP() llm.Tool {
return llm.Define[HTTPParams]("http_request", "Make an HTTP request and return the response",
func(ctx context.Context, p HTTPParams) (string, error) {
var bodyReader io.Reader
if p.Body != nil {
bodyReader = bytes.NewBufferString(*p.Body)
}
req, err := http.NewRequestWithContext(ctx, p.Method, p.URL, bodyReader)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
for k, v := range p.Headers {
req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
// Limit to 1MB
limited := io.LimitReader(resp.Body, 1<<20)
body, err := io.ReadAll(limited)
if err != nil {
return "", fmt.Errorf("reading response: %w", err)
}
headers := map[string]string{}
for k, v := range resp.Header {
if len(v) > 0 {
headers[k] = v[0]
}
}
result := map[string]any{
"status": resp.StatusCode,
"status_text": resp.Status,
"headers": headers,
"body": string(body),
}
out, _ := json.MarshalIndent(result, "", " ")
return string(out), nil
},
)
}

81
v2/tools/readfile.go Normal file
View File

@@ -0,0 +1,81 @@
package tools
import (
"bufio"
"context"
"fmt"
"os"
"strings"
llm "gitea.stevedudenhoeffer.com/steve/go-llm/v2"
)
// ReadFileParams defines parameters for the read file tool.
type ReadFileParams struct {
Path string `json:"path" description:"File path to read"`
Start *int `json:"start,omitempty" description:"Starting line number (1-based, inclusive)"`
End *int `json:"end,omitempty" description:"Ending line number (1-based, inclusive)"`
}
// ReadFile creates a file reading tool.
//
// Example:
//
// tools := llm.NewToolBox(tools.ReadFile())
func ReadFile() llm.Tool {
return llm.Define[ReadFileParams]("read_file", "Read the contents of a file",
func(ctx context.Context, p ReadFileParams) (string, error) {
f, err := os.Open(p.Path)
if err != nil {
return "", fmt.Errorf("opening file: %w", err)
}
defer f.Close()
// If no line range specified, read the whole file (limited to 1MB)
if p.Start == nil && p.End == nil {
info, err := f.Stat()
if err != nil {
return "", fmt.Errorf("stat file: %w", err)
}
if info.Size() > 1<<20 {
return "", fmt.Errorf("file too large (%d bytes), use start/end to read a range", info.Size())
}
data, err := os.ReadFile(p.Path)
if err != nil {
return "", fmt.Errorf("reading file: %w", err)
}
return string(data), nil
}
// Read specific line range
start := 1
end := -1
if p.Start != nil {
start = *p.Start
}
if p.End != nil {
end = *p.End
}
var lines []string
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
if lineNum < start {
continue
}
if end > 0 && lineNum > end {
break
}
lines = append(lines, fmt.Sprintf("%d: %s", lineNum, scanner.Text()))
}
if err := scanner.Err(); err != nil {
return "", fmt.Errorf("scanning file: %w", err)
}
return strings.Join(lines, "\n"), nil
},
)
}

101
v2/tools/websearch.go Normal file
View File

@@ -0,0 +1,101 @@
// Package tools provides ready-to-use tool implementations for common agent patterns.
package tools
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
llm "gitea.stevedudenhoeffer.com/steve/go-llm/v2"
)
// WebSearchParams defines parameters for the web search tool.
type WebSearchParams struct {
Query string `json:"query" description:"The search query"`
Count *int `json:"count,omitempty" description:"Number of results to return (default 5, max 20)"`
}
// WebSearch creates a web search tool using the Brave Search API.
//
// Get a free API key at https://brave.com/search/api/
//
// Example:
//
// tools := llm.NewToolBox(tools.WebSearch("your-brave-api-key"))
func WebSearch(apiKey string) llm.Tool {
return llm.Define[WebSearchParams]("web_search", "Search the web for information using Brave Search",
func(ctx context.Context, p WebSearchParams) (string, error) {
count := 5
if p.Count != nil && *p.Count > 0 {
count = *p.Count
if count > 20 {
count = 20
}
}
u := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d",
url.QueryEscape(p.Query), count)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Subscription-Token", apiKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("search request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("reading response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("search API returned %d: %s", resp.StatusCode, string(body))
}
// Parse and simplify the response
var raw map[string]any
if err := json.Unmarshal(body, &raw); err != nil {
return string(body), nil
}
type result struct {
Title string `json:"title"`
URL string `json:"url"`
Description string `json:"description"`
}
var results []result
if web, ok := raw["web"].(map[string]any); ok {
if items, ok := web["results"].([]any); ok {
for _, item := range items {
if m, ok := item.(map[string]any); ok {
r := result{}
if t, ok := m["title"].(string); ok {
r.Title = t
}
if u, ok := m["url"].(string); ok {
r.URL = u
}
if d, ok := m["description"].(string); ok {
r.Description = d
}
results = append(results, r)
}
}
}
}
out, _ := json.MarshalIndent(results, "", " ")
return string(out), nil
},
)
}

31
v2/tools/writefile.go Normal file
View File

@@ -0,0 +1,31 @@
package tools
import (
"context"
"fmt"
"os"
llm "gitea.stevedudenhoeffer.com/steve/go-llm/v2"
)
// WriteFileParams defines parameters for the write file tool.
type WriteFileParams struct {
Path string `json:"path" description:"File path to write"`
Content string `json:"content" description:"Content to write to the file"`
}
// WriteFile creates a file writing tool.
//
// Example:
//
// tools := llm.NewToolBox(tools.WriteFile())
func WriteFile() llm.Tool {
return llm.Define[WriteFileParams]("write_file", "Write content to a file (creates or overwrites)",
func(ctx context.Context, p WriteFileParams) (string, error) {
if err := os.WriteFile(p.Path, []byte(p.Content), 0644); err != nil {
return "", fmt.Errorf("writing file: %w", err)
}
return fmt.Sprintf("Successfully wrote %d bytes to %s", len(p.Content), p.Path), nil
},
)
}