Compare commits

...

32 Commits

Author SHA1 Message Date
9c1b4f7e9f Fix checksum typo for github.com/charmbracelet/bubbles in go.sum 2026-01-24 16:59:55 -05:00
2cf75ae07d Add MCP integration with MCPServer for tool-based interactions
- Introduce `MCPServer` to support connecting to MCP servers via stdio, SSE, or HTTP.
- Implement tool fetching, management, and invocation through MCP.
- Add `WithMCPServer` method to `ToolBox` for seamless tool integration.
- Extend schema package to handle raw JSON schemas for MCP tools.
- Update documentation with MCP usage guidelines and examples.
2026-01-24 16:25:28 -05:00
97d54c10ae Implement interactive CLI for LLM providers with chat, tools, and image support
- Add Bubble Tea-based CLI interface for LLM interactions.
- Implement `.env.example` for environment variable setup.
- Add provider, model, and tool selection screens.
- Include support for API key configuration.
- Enable chat interactions with optional image and tool support.
- Introduce core utility functions: image handling, tool execution, chat request management, and response rendering.
- Implement style customization with Lip Gloss.
2026-01-24 15:53:36 -05:00
bf7c86ab2a Refactor: modularize and streamline LLM providers and utility functions
- Migrate `compress_image.go` to `internal/imageutil` for better encapsulation.
- Reorganize LLM provider implementations into distinct packages (`google`, `openai`, and `anthropic`).
- Replace `go_llm` package name with `llm`.
- Refactor internal APIs for improved clarity, including renaming `anthropic` to `anthropicImpl` and `google` to `googleImpl`.
- Add helper methods and restructure message handling for better separation of concerns.
2026-01-24 15:40:38 -05:00
be99af3597 Update all dependencies and migrate to new Google genai SDK
- Update all Go dependencies to latest versions
- Migrate from github.com/google/generative-ai-go/genai to google.golang.org/genai
- Fix google.go to use the new SDK API (NewPartFromText, NewContentFromParts, etc.)
- Update schema package imports to use the new genai package
- Add CLAUDE.md with README maintenance guideline

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 15:22:34 -05:00
1927f4d187 woops messed up restriction 2025-08-08 11:07:40 -04:00
5fa7c7e5c7 Restrict temperature override for unsupported models (o* and gpt-5*). 2025-08-08 10:37:09 -04:00
07a04d08a9 Add WithDescription method to Function struct
Extend the `Function` struct with a `WithDescription` method to allow setting descriptions fluently.
2025-07-31 01:51:28 -04:00
31766134ef bubble up the mime type 2025-07-21 23:14:55 -04:00
e0adc40661 fix junie's bad idea 2025-07-21 22:53:11 -04:00
c73c63a8aa Merge remote-tracking branch 'origin/main' 2025-07-21 21:53:21 -04:00
101291abd9 handle base64 image resizing for anthropic 2025-07-21 21:53:13 -04:00
e9baf7910e Add LLM parsing functionality
Introduce `Providers` struct to handle different language model providers. Implement `Parse` method to extract and validate provider/model from input string, then return a chat completion interface. Add error handling for invalid formats or unknown providers.
2025-05-01 22:11:23 -04:00
39ffb82237 Add image handling for Gemini requests with URL download and base64 support 2025-04-12 15:07:44 -04:00
916f07be18 added anthropic tool support i think 2025-04-12 03:41:48 -04:00
3093b988f8 Refactor toolbox and function handling to support synthetic fields and improve type definitions 2025-04-12 02:20:40 -04:00
2ae583e9f3 Add support for required fields in parameter generation
Previously, required fields were not handled in OpenAI and Google parameter generation. This update adds logic to include a "required" list for both, ensuring mandatory fields are accurately captured in the schema outputs.
2025-04-07 20:33:21 -04:00
5ba0d5df7e instead of having an openai => google translation layer, just add sister functions to the types that construct the google request just like openai's 2025-04-07 01:57:02 -04:00
58552ee226 Fix enum typing 2025-04-06 15:35:05 -04:00
14961bfbc6 Refactor candidate parsing logic in Google adapter, which fixes only one tool call per execution 2025-04-06 14:35:22 -04:00
7c9eb08cb4 Add support for integers and tool configuration in schema handling
This update introduces support for `jsonschema.Integer` types and updates the logic to handle nested items in schemas. Added a new default error log for unknown types using `slog.Error`. Also, integrated tool configuration with a `FunctionCallingConfig` when `dontRequireTool` is false.
2025-04-06 01:23:10 -04:00
ff5e4ca7b0 Add support for integers and tool configuration in schema handling
This update introduces support for `jsonschema.Integer` types and updates the logic to handle nested items in schemas. Added a new default error log for unknown types using `slog.Error`. Also, integrated tool configuration with a `FunctionCallingConfig` when `dontRequireTool` is false.
2025-04-04 20:13:46 -04:00
82feb7d8b4 Change function result type from string to any
Updated the return type of functions and related code from `string` to `any` to improve flexibility and support more diverse outputs. Adjusted function implementations, signatures, and handling of results accordingly.
2025-03-25 23:53:09 -04:00
5ba42056ad Add toolbox features for function removal and callback execution
Introduced `WithFunctionRemoved` and `ExecuteCallbacks` methods to enhance `ToolBox` functionality. This allows dynamic function removal and execution of custom callbacks during tool call processing. Also cleaned up logging and improved handling for required tools in `openai.go`.
2025-03-21 11:09:32 -04:00
52533238d3 Add getter methods for response and toolcall in Context
Introduce `Response()` and `ToolCall()` methods to access the respective fields from the `Context` struct. This enhances encapsulation and provides a standardized way to retrieve these values.
2025-03-18 03:45:38 -04:00
88fbf89a63 Fix handling of OpenAI messages with content and multi-content.
Previously, OpenAI messages containing both `Content` and `MultiContent` could cause inconsistent behavior. This update ensures `Content` is converted into a `MultiContent` entry to maintain compatibility.
2025-03-18 01:01:46 -04:00
e5a046a70b Handle execution errors by appending them to the result.
Previously, execution errors were only returned in the refusal field. This update appends errors to the result field if present, ensuring they are included in the tool's output. This change improves visibility and clarity for error reporting.
2025-03-17 23:41:48 -04:00
2737a5b2be Refactor response handling for clarity and consistency.
Simplified how responses and tool calls are appended to conversations. Adjusted structure in message formatting to better align with tool call requirements, ensuring consistent data representation.
2025-03-17 00:18:32 -04:00
7f5e34e437 Refactor entire system to be more contextual so that conversation flow can be more easily managed 2025-03-16 22:38:58 -04:00
0d909edd44 Refactor Google LLM adapter to support tool schemas.
Enhanced the `requestToChatHistory` method to include OpenAI schema conversion logic and integrate tools with generative AI schemas. This change improves flexibility when working with different schema types and tool definitions. Adjusted response handling to return a modified model alongside chat sessions and parts.
2025-01-22 23:56:20 -05:00
388a44fa79 Refactor Google LLM API to use chat session interface.
Replace message handling with a chat session model, aligning the logic with new API requirements. Adjust functions to properly build chat history and send messages via chat sessions, improving compatibility and extensibility.
2025-01-22 22:07:20 -05:00
e7b7aab62e Refactor Toolbox handling in Google LLM integration.
Implemented a nil check for Toolbox to prevent potential nil pointer dereferences. Cleaned up and reorganized code for better readability and maintainability while keeping placeholder functionality intact.
2025-01-22 19:49:37 -05:00
37 changed files with 3528 additions and 446 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.claude
.idea
*.exe
.env

88
CLAUDE.md Normal file
View File

@@ -0,0 +1,88 @@
# CLAUDE.md for go-llm
## Build and Test Commands
- Build project: `go build ./...`
- Run all tests: `go test ./...`
- Run specific test: `go test -v -run <TestName> ./...`
- Tidy dependencies: `go mod tidy`
## Code Style Guidelines
- **Indentation**: Use standard Go tabs for indentation.
- **Naming**:
- Use `camelCase` for internal/private variables and functions.
- Use `PascalCase` for exported types, functions, and struct fields.
- Interface names should be concise (e.g., `LLM`, `ChatCompletion`).
- **Error Handling**:
- Always check and handle errors immediately.
- Wrap errors with context using `fmt.Errorf("%w: ...", err)`.
- Use the project's internal `Error` struct in `error.go` when differentiating between error types is needed.
- **Project Structure**:
- `llm.go`: Contains core interfaces (`LLM`, `ChatCompletion`) and shared types (`Message`, `Role`, `Image`).
- Provider implementations are in `openai.go`, `anthropic.go`, and `google.go`.
- Schema definitions for tool calling are in the `schema/` directory.
- `mcp.go`: MCP (Model Context Protocol) client integration for connecting to MCP servers.
- **Imports**: Organize imports into groups: standard library, then third-party libraries.
- **Documentation**: Use standard Go doc comments for exported symbols.
- **README.md**: The README.md file should always be kept up to date with any significant changes to the project.
## CLI Tool
- Build CLI: `go build ./cmd/llm`
- Run CLI: `./llm` (or `llm.exe` on Windows)
- Run without building: `go run ./cmd/llm`
### CLI Features
- Interactive TUI for testing all go-llm features
- Support for OpenAI, Anthropic, and Google providers
- Image input (file path, URL, or base64)
- Tool/function calling with demo tools
- Temperature control and settings
### Key Bindings
- `Enter` - Send message
- `Ctrl+I` - Add image
- `Ctrl+T` - Toggle tools panel
- `Ctrl+P` - Change provider
- `Ctrl+M` - Change model
- `Ctrl+S` - Settings
- `Ctrl+N` - New conversation
- `Esc` - Exit/Cancel
## MCP (Model Context Protocol) Support
The library supports connecting to MCP servers to use their tools. MCP servers can be connected via:
- **stdio**: Run a command as a subprocess
- **sse**: Connect to an SSE endpoint
- **http**: Connect to a streamable HTTP endpoint
### Usage Example
```go
ctx := context.Background()
// Create and connect to an MCP server
server := &llm.MCPServer{
Name: "my-server",
Command: "my-mcp-server",
Args: []string{"--some-flag"},
}
if err := server.Connect(ctx); err != nil {
log.Fatal(err)
}
defer server.Close()
// Add the server to a toolbox
toolbox := llm.NewToolBox().WithMCPServer(server)
// Use the toolbox in requests - MCP tools are automatically available
req := llm.Request{
Messages: []llm.Message{{Role: llm.RoleUser, Text: "Use the MCP tool"}},
Toolbox: toolbox,
}
```
### MCPServer Options
- `Name`: Friendly name for logging
- `Command`: Command to run (for stdio transport)
- `Args`: Command arguments
- `Env`: Additional environment variables
- `URL`: Endpoint URL (for sse/http transport)
- `Transport`: "stdio" (default), "sse", or "http"

View File

@@ -1,7 +1,8 @@
package go_llm
package llm
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
@@ -9,17 +10,19 @@ import (
"log/slog"
"net/http"
"gitea.stevedudenhoeffer.com/steve/go-llm/internal/imageutil"
anth "github.com/liushuangls/go-anthropic/v2"
)
type anthropic struct {
type anthropicImpl struct {
key string
model string
}
var _ LLM = anthropic{}
var _ LLM = anthropicImpl{}
func (a anthropic) ModelVersion(modelVersion string) (ChatCompletion, error) {
func (a anthropicImpl) ModelVersion(modelVersion string) (ChatCompletion, error) {
a.model = modelVersion
// TODO: model verification?
@@ -33,7 +36,7 @@ func deferClose(c io.Closer) {
}
}
func (a anthropic) requestToAnthropicRequest(req Request) anth.MessagesRequest {
func (a anthropicImpl) requestToAnthropicRequest(req Request) anth.MessagesRequest {
res := anth.MessagesRequest{
Model: anth.Model(a.model),
MaxTokens: 1000,
@@ -77,6 +80,27 @@ func (a anthropic) requestToAnthropicRequest(req Request) anth.MessagesRequest {
}
if img.Base64 != "" {
// Anthropic models expect images to be < 5MiB in size
raw, err := base64.StdEncoding.DecodeString(img.Base64)
if err != nil {
continue
}
// Check if image size exceeds 5MiB (5242880 bytes)
if len(raw) >= 5242880 {
compressed, mime, err := imageutil.CompressImage(img.Base64, 5*1024*1024)
// just replace the image with the compressed one
if err != nil {
continue
}
img.Base64 = compressed
img.ContentType = mime
}
m.Content = append(m.Content, anth.NewImageMessageContent(
anth.NewMessageContentSource(
anth.MessagesContentSourceTypeBase64,
@@ -123,7 +147,6 @@ func (a anthropic) requestToAnthropicRequest(req Request) anth.MessagesRequest {
// if this has the same role as the previous message, we can append it to the previous message
// as anthropic expects alternating assistant and user roles
if len(msgs) > 0 && msgs[len(msgs)-1].Role == role {
m2 := &msgs[len(msgs)-1]
@@ -134,20 +157,19 @@ func (a anthropic) requestToAnthropicRequest(req Request) anth.MessagesRequest {
}
}
if req.Toolbox != nil {
for _, tool := range req.Toolbox.funcs {
res.Tools = append(res.Tools, anth.ToolDefinition{
Name: tool.Name,
Description: tool.Description,
InputSchema: tool.Parameters,
})
}
for _, tool := range req.Toolbox.Functions() {
res.Tools = append(res.Tools, anth.ToolDefinition{
Name: tool.Name,
Description: tool.Description,
InputSchema: tool.Parameters.AnthropicInputSchema(),
})
}
res.Messages = msgs
if req.Temperature != nil {
res.Temperature = req.Temperature
var f = float32(*req.Temperature)
res.Temperature = &f
}
log.Println("llm request to anthropic request", res)
@@ -155,16 +177,14 @@ func (a anthropic) requestToAnthropicRequest(req Request) anth.MessagesRequest {
return res
}
func (a anthropic) responseToLLMResponse(in anth.MessagesResponse) Response {
res := Response{}
func (a anthropicImpl) responseToLLMResponse(in anth.MessagesResponse) Response {
choice := ResponseChoice{}
for _, msg := range in.Content {
choice := ResponseChoice{}
switch msg.Type {
case anth.MessagesContentTypeText:
if msg.Text != nil {
choice.Content = *msg.Text
choice.Content += *msg.Text
}
case anth.MessagesContentTypeToolUse:
@@ -183,16 +203,16 @@ func (a anthropic) responseToLLMResponse(in anth.MessagesResponse) Response {
}
}
}
res.Choices = append(res.Choices, choice)
}
log.Println("anthropic response to llm response", res)
log.Println("anthropic response to llm response", choice)
return res
return Response{
Choices: []ResponseChoice{choice},
}
}
func (a anthropic) ChatComplete(ctx context.Context, req Request) (Response, error) {
func (a anthropicImpl) ChatComplete(ctx context.Context, req Request) (Response, error) {
cl := anth.NewClient(a.key)
res, err := cl.CreateMessages(ctx, a.requestToAnthropicRequest(req))

11
cmd/llm/.env.example Normal file
View File

@@ -0,0 +1,11 @@
# go-llm CLI Environment Variables
# Copy this file to .env and fill in your API keys
# OpenAI API Key (https://platform.openai.com/api-keys)
OPENAI_API_KEY=
# Anthropic API Key (https://console.anthropic.com/settings/keys)
ANTHROPIC_API_KEY=
# Google AI API Key (https://aistudio.google.com/apikey)
GOOGLE_API_KEY=

182
cmd/llm/commands.go Normal file
View File

@@ -0,0 +1,182 @@
package main
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"os"
"strings"
tea "github.com/charmbracelet/bubbletea"
llm "gitea.stevedudenhoeffer.com/steve/go-llm"
)
// Message types for async operations
// ChatResponseMsg contains the response from a chat completion
type ChatResponseMsg struct {
Response llm.Response
Err error
}
// ToolExecutionMsg contains results from tool execution
type ToolExecutionMsg struct {
Results []llm.ToolCallResponse
Err error
}
// ImageLoadedMsg contains a loaded image
type ImageLoadedMsg struct {
Image llm.Image
Err error
}
// sendChatRequest sends a chat completion request
func sendChatRequest(chat llm.ChatCompletion, req llm.Request) tea.Cmd {
return func() tea.Msg {
resp, err := chat.ChatComplete(context.Background(), req)
return ChatResponseMsg{Response: resp, Err: err}
}
}
// executeTools executes tool calls and returns results
func executeTools(toolbox llm.ToolBox, req llm.Request, resp llm.ResponseChoice) tea.Cmd {
return func() tea.Msg {
ctx := llm.NewContext(context.Background(), req, &resp, nil)
var results []llm.ToolCallResponse
for _, call := range resp.Calls {
result, err := toolbox.Execute(ctx, call)
results = append(results, llm.ToolCallResponse{
ID: call.ID,
Result: result,
Error: err,
})
}
return ToolExecutionMsg{Results: results, Err: nil}
}
}
// loadImageFromPath loads an image from a file path
func loadImageFromPath(path string) tea.Cmd {
return func() tea.Msg {
// Clean up the path
path = strings.TrimSpace(path)
path = strings.Trim(path, "\"'")
// Read the file
data, err := os.ReadFile(path)
if err != nil {
return ImageLoadedMsg{Err: fmt.Errorf("failed to read image file: %w", err)}
}
// Detect content type
contentType := http.DetectContentType(data)
if !strings.HasPrefix(contentType, "image/") {
return ImageLoadedMsg{Err: fmt.Errorf("file is not an image: %s", contentType)}
}
// Base64 encode
encoded := base64.StdEncoding.EncodeToString(data)
return ImageLoadedMsg{
Image: llm.Image{
Base64: encoded,
ContentType: contentType,
},
}
}
}
// loadImageFromURL loads an image from a URL
func loadImageFromURL(url string) tea.Cmd {
return func() tea.Msg {
url = strings.TrimSpace(url)
// For URL images, we can just use the URL directly
return ImageLoadedMsg{
Image: llm.Image{
Url: url,
},
}
}
}
// loadImageFromBase64 loads an image from base64 data
func loadImageFromBase64(data string) tea.Cmd {
return func() tea.Msg {
data = strings.TrimSpace(data)
// Check if it's a data URL
if strings.HasPrefix(data, "data:") {
// Parse data URL: data:image/png;base64,....
parts := strings.SplitN(data, ",", 2)
if len(parts) != 2 {
return ImageLoadedMsg{Err: fmt.Errorf("invalid data URL format")}
}
// Extract content type from first part
mediaType := strings.TrimPrefix(parts[0], "data:")
mediaType = strings.TrimSuffix(mediaType, ";base64")
return ImageLoadedMsg{
Image: llm.Image{
Base64: parts[1],
ContentType: mediaType,
},
}
}
// Assume it's raw base64, try to detect content type
decoded, err := base64.StdEncoding.DecodeString(data)
if err != nil {
return ImageLoadedMsg{Err: fmt.Errorf("invalid base64 data: %w", err)}
}
contentType := http.DetectContentType(decoded)
if !strings.HasPrefix(contentType, "image/") {
return ImageLoadedMsg{Err: fmt.Errorf("data is not an image: %s", contentType)}
}
return ImageLoadedMsg{
Image: llm.Image{
Base64: data,
ContentType: contentType,
},
}
}
}
// buildRequest builds a chat request from the current state
func buildRequest(m *Model, userText string) llm.Request {
// Create the user message with any pending images
userMsg := llm.Message{
Role: llm.RoleUser,
Text: userText,
Images: m.pendingImages,
}
req := llm.Request{
Conversation: m.conversation,
Messages: []llm.Message{
{Role: llm.RoleSystem, Text: m.systemPrompt},
userMsg,
},
Temperature: m.temperature,
}
// Add toolbox if enabled
if m.toolsEnabled && len(m.toolbox.Functions()) > 0 {
req.Toolbox = m.toolbox.WithRequireTool(false)
}
return req
}
// buildFollowUpRequest builds a follow-up request after tool execution
func buildFollowUpRequest(m *Model, previousReq llm.Request, resp llm.ResponseChoice, toolResults []llm.ToolCallResponse) llm.Request {
return previousReq.NextRequest(resp, toolResults)
}

25
cmd/llm/main.go Normal file
View File

@@ -0,0 +1,25 @@
package main
import (
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
"github.com/joho/godotenv"
)
func main() {
// Load .env file if it exists (ignore error if not found)
_ = godotenv.Load()
p := tea.NewProgram(
InitialModel(),
tea.WithAltScreen(),
tea.WithMouseCellMotion(),
)
if _, err := p.Run(); err != nil {
fmt.Printf("Error running program: %v\n", err)
os.Exit(1)
}
}

295
cmd/llm/model.go Normal file
View File

@@ -0,0 +1,295 @@
package main
import (
"os"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
llm "gitea.stevedudenhoeffer.com/steve/go-llm"
)
// State represents the current view/screen of the application
type State int
const (
StateChat State = iota
StateProviderSelect
StateModelSelect
StateImageInput
StateToolsPanel
StateSettings
StateAPIKeyInput
)
// DisplayMessage represents a message for display in the UI
type DisplayMessage struct {
Role llm.Role
Content string
Images int // number of images attached
}
// ProviderInfo contains information about a provider
type ProviderInfo struct {
Name string
EnvVar string
Models []string
HasAPIKey bool
ModelIndex int
}
// Model is the main Bubble Tea model
type Model struct {
// State
state State
previousState State
// Provider
provider llm.LLM
providerName string
chat llm.ChatCompletion
modelName string
apiKeys map[string]string
providers []ProviderInfo
providerIndex int
// Conversation
conversation []llm.Input
messages []DisplayMessage
// Tools
toolbox llm.ToolBox
toolsEnabled bool
// Settings
systemPrompt string
temperature *float64
// Pending images
pendingImages []llm.Image
// UI Components
input textinput.Model
viewport viewport.Model
viewportReady bool
// Selection state (for lists)
listIndex int
listItems []string
// Dimensions
width int
height int
// Loading state
loading bool
err error
// For API key input
apiKeyInput textinput.Model
}
// InitialModel creates and returns the initial model
func InitialModel() Model {
ti := textinput.New()
ti.Placeholder = "Type your message..."
ti.Focus()
ti.CharLimit = 4096
ti.Width = 60
aki := textinput.New()
aki.Placeholder = "Enter API key..."
aki.CharLimit = 256
aki.Width = 60
aki.EchoMode = textinput.EchoPassword
// Initialize providers with environment variable checks
providers := []ProviderInfo{
{
Name: "OpenAI",
EnvVar: "OPENAI_API_KEY",
Models: []string{
"gpt-4.1",
"gpt-4.1-mini",
"gpt-4.1-nano",
"gpt-4o",
"gpt-4o-mini",
"gpt-4-turbo",
"gpt-3.5-turbo",
"o1",
"o1-mini",
"o1-preview",
"o3-mini",
},
},
{
Name: "Anthropic",
EnvVar: "ANTHROPIC_API_KEY",
Models: []string{
"claude-sonnet-4-20250514",
"claude-opus-4-20250514",
"claude-3-7-sonnet-20250219",
"claude-3-5-sonnet-20241022",
"claude-3-5-haiku-20241022",
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
"claude-3-haiku-20240307",
},
},
{
Name: "Google",
EnvVar: "GOOGLE_API_KEY",
Models: []string{
"gemini-2.0-flash",
"gemini-2.0-flash-lite",
"gemini-1.5-pro",
"gemini-1.5-flash",
"gemini-1.5-flash-8b",
"gemini-1.0-pro",
},
},
}
// Check for API keys in environment
apiKeys := make(map[string]string)
for i := range providers {
if key := os.Getenv(providers[i].EnvVar); key != "" {
apiKeys[providers[i].Name] = key
providers[i].HasAPIKey = true
}
}
m := Model{
state: StateProviderSelect,
input: ti,
apiKeyInput: aki,
apiKeys: apiKeys,
providers: providers,
systemPrompt: "You are a helpful assistant.",
toolbox: createDemoToolbox(),
toolsEnabled: false,
messages: []DisplayMessage{},
conversation: []llm.Input{},
}
// Build list items for provider selection
m.listItems = make([]string, len(providers))
for i, p := range providers {
status := " (no key)"
if p.HasAPIKey {
status = " (ready)"
}
m.listItems[i] = p.Name + status
}
return m
}
// Init initializes the model
func (m Model) Init() tea.Cmd {
return textinput.Blink
}
// selectProvider sets up the selected provider
func (m *Model) selectProvider(index int) error {
if index < 0 || index >= len(m.providers) {
return nil
}
p := m.providers[index]
key, ok := m.apiKeys[p.Name]
if !ok || key == "" {
return nil
}
m.providerName = p.Name
m.providerIndex = index
switch p.Name {
case "OpenAI":
m.provider = llm.OpenAI(key)
case "Anthropic":
m.provider = llm.Anthropic(key)
case "Google":
m.provider = llm.Google(key)
}
// Select default model
if len(p.Models) > 0 {
return m.selectModel(p.ModelIndex)
}
return nil
}
// selectModel sets the current model
func (m *Model) selectModel(index int) error {
if m.provider == nil {
return nil
}
p := m.providers[m.providerIndex]
if index < 0 || index >= len(p.Models) {
return nil
}
modelName := p.Models[index]
chat, err := m.provider.ModelVersion(modelName)
if err != nil {
return err
}
m.chat = chat
m.modelName = modelName
m.providers[m.providerIndex].ModelIndex = index
return nil
}
// newConversation resets the conversation
func (m *Model) newConversation() {
m.conversation = []llm.Input{}
m.messages = []DisplayMessage{}
m.pendingImages = []llm.Image{}
m.err = nil
}
// addUserMessage adds a user message to the conversation
func (m *Model) addUserMessage(text string, images []llm.Image) {
msg := llm.Message{
Role: llm.RoleUser,
Text: text,
Images: images,
}
m.conversation = append(m.conversation, msg)
m.messages = append(m.messages, DisplayMessage{
Role: llm.RoleUser,
Content: text,
Images: len(images),
})
}
// addAssistantMessage adds an assistant message to the conversation
func (m *Model) addAssistantMessage(content string) {
m.messages = append(m.messages, DisplayMessage{
Role: llm.RoleAssistant,
Content: content,
})
}
// addToolCallMessage adds a tool call message to display
func (m *Model) addToolCallMessage(name string, args string) {
m.messages = append(m.messages, DisplayMessage{
Role: llm.Role("tool_call"),
Content: name + ": " + args,
})
}
// addToolResultMessage adds a tool result message to display
func (m *Model) addToolResultMessage(name string, result string) {
m.messages = append(m.messages, DisplayMessage{
Role: llm.Role("tool_result"),
Content: name + " -> " + result,
})
}

113
cmd/llm/styles.go Normal file
View File

@@ -0,0 +1,113 @@
package main
import (
"github.com/charmbracelet/lipgloss"
)
var (
// Colors
primaryColor = lipgloss.Color("205")
secondaryColor = lipgloss.Color("39")
accentColor = lipgloss.Color("212")
mutedColor = lipgloss.Color("241")
errorColor = lipgloss.Color("196")
successColor = lipgloss.Color("82")
// App styles
appStyle = lipgloss.NewStyle().Padding(1, 2)
// Header
headerStyle = lipgloss.NewStyle().
Bold(true).
Foreground(primaryColor).
BorderStyle(lipgloss.NormalBorder()).
BorderBottom(true).
BorderForeground(mutedColor).
Padding(0, 1)
// Provider badge
providerBadgeStyle = lipgloss.NewStyle().
Background(secondaryColor).
Foreground(lipgloss.Color("0")).
Padding(0, 1).
Bold(true)
// Messages
systemMsgStyle = lipgloss.NewStyle().
Foreground(mutedColor).
Italic(true).
Padding(0, 1)
userMsgStyle = lipgloss.NewStyle().
Foreground(secondaryColor).
Padding(0, 1)
assistantMsgStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("255")).
Padding(0, 1)
roleLabelStyle = lipgloss.NewStyle().
Bold(true).
Width(12)
// Tool calls
toolCallStyle = lipgloss.NewStyle().
Foreground(accentColor).
Italic(true).
Padding(0, 1)
toolResultStyle = lipgloss.NewStyle().
Foreground(successColor).
Padding(0, 1)
// Input area
inputStyle = lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(primaryColor).
Padding(0, 1)
inputHelpStyle = lipgloss.NewStyle().
Foreground(mutedColor).
Italic(true)
// Error
errorStyle = lipgloss.NewStyle().
Foreground(errorColor).
Bold(true)
// Loading
loadingStyle = lipgloss.NewStyle().
Foreground(accentColor).
Italic(true)
// List selection
selectedItemStyle = lipgloss.NewStyle().
Foreground(primaryColor).
Bold(true)
normalItemStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("255"))
// Settings panel
settingLabelStyle = lipgloss.NewStyle().
Foreground(secondaryColor).
Width(15)
settingValueStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("255"))
// Help text
helpStyle = lipgloss.NewStyle().
Foreground(mutedColor).
Padding(1, 0)
// Image indicator
imageIndicatorStyle = lipgloss.NewStyle().
Foreground(accentColor).
Bold(true)
// Viewport
viewportStyle = lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(mutedColor)
)

105
cmd/llm/tools.go Normal file
View File

@@ -0,0 +1,105 @@
package main
import (
"fmt"
"math"
"strconv"
"strings"
"time"
llm "gitea.stevedudenhoeffer.com/steve/go-llm"
)
// TimeParams is the parameter struct for the GetTime function
type TimeParams struct{}
// GetTime returns the current time
func GetTime(_ *llm.Context, _ TimeParams) (any, error) {
return time.Now().Format("Monday, January 2, 2006 3:04:05 PM MST"), nil
}
// CalcParams is the parameter struct for the Calculate function
type CalcParams struct {
A float64 `json:"a" description:"First number"`
B float64 `json:"b" description:"Second number"`
Op string `json:"op" description:"Operation: add, subtract, multiply, divide, power, sqrt, mod"`
}
// Calculate performs basic math operations
func Calculate(_ *llm.Context, params CalcParams) (any, error) {
switch strings.ToLower(params.Op) {
case "add", "+":
return params.A + params.B, nil
case "subtract", "sub", "-":
return params.A - params.B, nil
case "multiply", "mul", "*":
return params.A * params.B, nil
case "divide", "div", "/":
if params.B == 0 {
return nil, fmt.Errorf("division by zero")
}
return params.A / params.B, nil
case "power", "pow", "^":
return math.Pow(params.A, params.B), nil
case "sqrt":
if params.A < 0 {
return nil, fmt.Errorf("cannot take square root of negative number")
}
return math.Sqrt(params.A), nil
case "mod", "%":
return math.Mod(params.A, params.B), nil
default:
return nil, fmt.Errorf("unknown operation: %s", params.Op)
}
}
// WeatherParams is the parameter struct for the GetWeather function
type WeatherParams struct {
Location string `json:"location" description:"City name or location"`
}
// GetWeather returns mock weather data (for demo purposes)
func GetWeather(_ *llm.Context, params WeatherParams) (any, error) {
// This is a demo function - returns mock data
weathers := []string{"sunny", "cloudy", "rainy", "partly cloudy", "windy"}
temps := []int{65, 72, 58, 80, 45}
// Use location string to deterministically pick weather
idx := len(params.Location) % len(weathers)
return map[string]any{
"location": params.Location,
"temperature": strconv.Itoa(temps[idx]) + "F",
"condition": weathers[idx],
"humidity": "45%",
"note": "This is mock data for demonstration purposes",
}, nil
}
// RandomNumberParams is the parameter struct for the RandomNumber function
type RandomNumberParams struct {
Min int `json:"min" description:"Minimum value (inclusive)"`
Max int `json:"max" description:"Maximum value (inclusive)"`
}
// RandomNumber generates a pseudo-random number (using current time nanoseconds)
func RandomNumber(_ *llm.Context, params RandomNumberParams) (any, error) {
if params.Min > params.Max {
return nil, fmt.Errorf("min cannot be greater than max")
}
// Simple pseudo-random using time
n := time.Now().UnixNano()
rangeSize := params.Max - params.Min + 1
result := params.Min + int(n%int64(rangeSize))
return result, nil
}
// createDemoToolbox creates a toolbox with demo tools for testing
func createDemoToolbox() llm.ToolBox {
return llm.NewToolBox(
llm.NewFunction("get_time", "Get the current date and time", GetTime),
llm.NewFunction("calculate", "Perform basic math operations (add, subtract, multiply, divide, power, sqrt, mod)", Calculate),
llm.NewFunction("get_weather", "Get weather information for a location (demo data)", GetWeather),
llm.NewFunction("random_number", "Generate a random number between min and max", RandomNumber),
)
}

435
cmd/llm/update.go Normal file
View File

@@ -0,0 +1,435 @@
package main
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
llm "gitea.stevedudenhoeffer.com/steve/go-llm"
)
// pendingRequest stores the request being processed for follow-up
var pendingRequest llm.Request
var pendingResponse llm.ResponseChoice
// Update handles messages and updates the model
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
return m.handleKeyMsg(msg)
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
headerHeight := 3
footerHeight := 4
verticalMargins := headerHeight + footerHeight
if !m.viewportReady {
m.viewport = viewport.New(msg.Width-4, msg.Height-verticalMargins)
m.viewport.HighPerformanceRendering = false
m.viewportReady = true
} else {
m.viewport.Width = msg.Width - 4
m.viewport.Height = msg.Height - verticalMargins
}
m.input.Width = msg.Width - 6
m.apiKeyInput.Width = msg.Width - 6
m.viewport.SetContent(m.renderMessages())
case ChatResponseMsg:
m.loading = false
if msg.Err != nil {
m.err = msg.Err
return m, nil
}
if len(msg.Response.Choices) == 0 {
m.err = fmt.Errorf("no response choices returned")
return m, nil
}
choice := msg.Response.Choices[0]
// Check for tool calls
if len(choice.Calls) > 0 && m.toolsEnabled {
// Store for follow-up
pendingResponse = choice
// Add assistant's response to conversation if there's content
if choice.Content != "" {
m.addAssistantMessage(choice.Content)
}
// Display tool calls
for _, call := range choice.Calls {
m.addToolCallMessage(call.FunctionCall.Name, call.FunctionCall.Arguments)
}
m.viewport.SetContent(m.renderMessages())
m.viewport.GotoBottom()
// Execute tools
m.loading = true
return m, executeTools(m.toolbox, pendingRequest, choice)
}
// Regular response - add to conversation and display
m.conversation = append(m.conversation, choice)
m.addAssistantMessage(choice.Content)
m.viewport.SetContent(m.renderMessages())
m.viewport.GotoBottom()
case ToolExecutionMsg:
if msg.Err != nil {
m.loading = false
m.err = msg.Err
return m, nil
}
// Display tool results
for i, result := range msg.Results {
name := pendingResponse.Calls[i].FunctionCall.Name
resultStr := fmt.Sprintf("%v", result.Result)
if result.Error != nil {
resultStr = "Error: " + result.Error.Error()
}
m.addToolResultMessage(name, resultStr)
}
// Add tool call responses to conversation
for _, result := range msg.Results {
m.conversation = append(m.conversation, result)
}
// Add the assistant's response to conversation
m.conversation = append(m.conversation, pendingResponse)
m.viewport.SetContent(m.renderMessages())
m.viewport.GotoBottom()
// Send follow-up request
followUp := buildFollowUpRequest(&m, pendingRequest, pendingResponse, msg.Results)
pendingRequest = followUp
return m, sendChatRequest(m.chat, followUp)
case ImageLoadedMsg:
if msg.Err != nil {
m.err = msg.Err
m.state = m.previousState
return m, nil
}
m.pendingImages = append(m.pendingImages, msg.Image)
m.state = m.previousState
m.err = nil
default:
// Update text input
if m.state == StateChat {
m.input, cmd = m.input.Update(msg)
cmds = append(cmds, cmd)
} else if m.state == StateAPIKeyInput {
m.apiKeyInput, cmd = m.apiKeyInput.Update(msg)
cmds = append(cmds, cmd)
}
}
return m, tea.Batch(cmds...)
}
// handleKeyMsg handles keyboard input
func (m Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// Global key handling
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "esc":
if m.state != StateChat {
m.state = StateChat
m.input.Focus()
return m, nil
}
return m, tea.Quit
}
// State-specific key handling
switch m.state {
case StateChat:
return m.handleChatKeys(msg)
case StateProviderSelect:
return m.handleProviderSelectKeys(msg)
case StateModelSelect:
return m.handleModelSelectKeys(msg)
case StateImageInput:
return m.handleImageInputKeys(msg)
case StateToolsPanel:
return m.handleToolsPanelKeys(msg)
case StateSettings:
return m.handleSettingsKeys(msg)
case StateAPIKeyInput:
return m.handleAPIKeyInputKeys(msg)
}
return m, nil
}
// handleChatKeys handles keys in chat state
func (m Model) handleChatKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "enter":
if m.loading {
return m, nil
}
text := strings.TrimSpace(m.input.Value())
if text == "" {
return m, nil
}
if m.chat == nil {
m.err = fmt.Errorf("no model selected - press Ctrl+P to select a provider")
return m, nil
}
// Build and send request
req := buildRequest(&m, text)
pendingRequest = req
// Add user message to display
m.addUserMessage(text, m.pendingImages)
// Clear input and pending images
m.input.Reset()
m.pendingImages = nil
m.err = nil
m.loading = true
m.viewport.SetContent(m.renderMessages())
m.viewport.GotoBottom()
return m, sendChatRequest(m.chat, req)
case "ctrl+i":
m.previousState = StateChat
m.state = StateImageInput
m.input.SetValue("")
m.input.Placeholder = "Enter image path or URL..."
return m, nil
case "ctrl+t":
m.state = StateToolsPanel
return m, nil
case "ctrl+p":
m.state = StateProviderSelect
m.listIndex = m.providerIndex
return m, nil
case "ctrl+m":
if m.provider == nil {
m.err = fmt.Errorf("select a provider first")
return m, nil
}
m.state = StateModelSelect
m.listItems = m.providers[m.providerIndex].Models
m.listIndex = m.providers[m.providerIndex].ModelIndex
return m, nil
case "ctrl+s":
m.state = StateSettings
return m, nil
case "ctrl+n":
m.newConversation()
m.viewport.SetContent(m.renderMessages())
return m, nil
case "up", "down", "pgup", "pgdown":
var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
return m, cmd
default:
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
return m, cmd
}
}
// handleProviderSelectKeys handles keys in provider selection state
func (m Model) handleProviderSelectKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "up", "k":
if m.listIndex > 0 {
m.listIndex--
}
case "down", "j":
if m.listIndex < len(m.providers)-1 {
m.listIndex++
}
case "enter":
p := m.providers[m.listIndex]
if !p.HasAPIKey {
// Need to get API key
m.state = StateAPIKeyInput
m.apiKeyInput.Focus()
m.apiKeyInput.SetValue("")
return m, textinput.Blink
}
err := m.selectProvider(m.listIndex)
if err != nil {
m.err = err
return m, nil
}
m.state = StateChat
m.input.Focus()
m.newConversation()
return m, nil
}
return m, nil
}
// handleAPIKeyInputKeys handles keys in API key input state
func (m Model) handleAPIKeyInputKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "enter":
key := strings.TrimSpace(m.apiKeyInput.Value())
if key == "" {
return m, nil
}
// Store the API key
p := m.providers[m.listIndex]
m.apiKeys[p.Name] = key
m.providers[m.listIndex].HasAPIKey = true
// Update list items
for i, prov := range m.providers {
status := " (no key)"
if prov.HasAPIKey {
status = " (ready)"
}
m.listItems[i] = prov.Name + status
}
// Select the provider
err := m.selectProvider(m.listIndex)
if err != nil {
m.err = err
return m, nil
}
m.state = StateChat
m.input.Focus()
m.newConversation()
return m, nil
default:
var cmd tea.Cmd
m.apiKeyInput, cmd = m.apiKeyInput.Update(msg)
return m, cmd
}
}
// handleModelSelectKeys handles keys in model selection state
func (m Model) handleModelSelectKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "up", "k":
if m.listIndex > 0 {
m.listIndex--
}
case "down", "j":
if m.listIndex < len(m.listItems)-1 {
m.listIndex++
}
case "enter":
err := m.selectModel(m.listIndex)
if err != nil {
m.err = err
return m, nil
}
m.state = StateChat
m.input.Focus()
}
return m, nil
}
// handleImageInputKeys handles keys in image input state
func (m Model) handleImageInputKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "enter":
input := strings.TrimSpace(m.input.Value())
if input == "" {
m.state = m.previousState
m.input.Placeholder = "Type your message..."
return m, nil
}
m.input.Placeholder = "Type your message..."
// Determine input type and load
if strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "https://") {
return m, loadImageFromURL(input)
} else if strings.HasPrefix(input, "data:") || len(input) > 100 && !strings.Contains(input, "/") && !strings.Contains(input, "\\") {
return m, loadImageFromBase64(input)
} else {
return m, loadImageFromPath(input)
}
default:
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
return m, cmd
}
}
// handleToolsPanelKeys handles keys in tools panel state
func (m Model) handleToolsPanelKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "t":
m.toolsEnabled = !m.toolsEnabled
case "enter", "q":
m.state = StateChat
m.input.Focus()
}
return m, nil
}
// handleSettingsKeys handles keys in settings state
func (m Model) handleSettingsKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "1":
// Set temperature to nil (default)
m.temperature = nil
case "2":
t := 0.0
m.temperature = &t
case "3":
t := 0.5
m.temperature = &t
case "4":
t := 0.7
m.temperature = &t
case "5":
t := 1.0
m.temperature = &t
case "enter", "q":
m.state = StateChat
m.input.Focus()
}
return m, nil
}

296
cmd/llm/view.go Normal file
View File

@@ -0,0 +1,296 @@
package main
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
llm "gitea.stevedudenhoeffer.com/steve/go-llm"
)
// View renders the current state
func (m Model) View() string {
switch m.state {
case StateProviderSelect:
return m.renderProviderSelect()
case StateModelSelect:
return m.renderModelSelect()
case StateImageInput:
return m.renderImageInput()
case StateToolsPanel:
return m.renderToolsPanel()
case StateSettings:
return m.renderSettings()
case StateAPIKeyInput:
return m.renderAPIKeyInput()
default:
return m.renderChat()
}
}
// renderChat renders the main chat view
func (m Model) renderChat() string {
var b strings.Builder
// Header
provider := m.providerName
if provider == "" {
provider = "None"
}
model := m.modelName
if model == "" {
model = "None"
}
header := headerStyle.Render(fmt.Sprintf("go-llm CLI %s",
providerBadgeStyle.Render(fmt.Sprintf("%s/%s", provider, model))))
b.WriteString(header)
b.WriteString("\n")
// Messages viewport
if m.viewportReady {
b.WriteString(m.viewport.View())
b.WriteString("\n")
}
// Image indicator
if len(m.pendingImages) > 0 {
b.WriteString(imageIndicatorStyle.Render(fmt.Sprintf(" [%d image(s) attached]", len(m.pendingImages))))
b.WriteString("\n")
}
// Error
if m.err != nil {
b.WriteString(errorStyle.Render(" Error: " + m.err.Error()))
b.WriteString("\n")
}
// Loading
if m.loading {
b.WriteString(loadingStyle.Render(" Thinking..."))
b.WriteString("\n")
}
// Input
inputBox := inputStyle.Render(m.input.View())
b.WriteString(inputBox)
b.WriteString("\n")
// Help
help := inputHelpStyle.Render("Enter: send | Ctrl+I: image | Ctrl+T: tools | Ctrl+P: provider | Ctrl+M: model | Ctrl+S: settings | Ctrl+N: new | Esc: quit")
b.WriteString(help)
return appStyle.Render(b.String())
}
// renderMessages renders all messages for the viewport
func (m Model) renderMessages() string {
var b strings.Builder
if len(m.messages) == 0 {
b.WriteString(systemMsgStyle.Render("[System] " + m.systemPrompt))
b.WriteString("\n\n")
b.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render("Start a conversation by typing a message below."))
return b.String()
}
b.WriteString(systemMsgStyle.Render("[System] " + m.systemPrompt))
b.WriteString("\n\n")
for _, msg := range m.messages {
var content string
var style lipgloss.Style
switch msg.Role {
case llm.RoleUser:
style = userMsgStyle
label := roleLabelStyle.Foreground(secondaryColor).Render("[User]")
content = label + " " + msg.Content
if msg.Images > 0 {
content += imageIndicatorStyle.Render(fmt.Sprintf(" [%d image(s)]", msg.Images))
}
case llm.RoleAssistant:
style = assistantMsgStyle
label := roleLabelStyle.Foreground(lipgloss.Color("255")).Render("[Assistant]")
content = label + " " + msg.Content
case llm.Role("tool_call"):
style = toolCallStyle
content = " -> Calling: " + msg.Content
case llm.Role("tool_result"):
style = toolResultStyle
content = " <- Result: " + msg.Content
default:
style = assistantMsgStyle
content = msg.Content
}
b.WriteString(style.Render(content))
b.WriteString("\n\n")
}
return b.String()
}
// renderProviderSelect renders the provider selection view
func (m Model) renderProviderSelect() string {
var b strings.Builder
b.WriteString(headerStyle.Render("Select Provider"))
b.WriteString("\n\n")
for i, item := range m.listItems {
cursor := " "
style := normalItemStyle
if i == m.listIndex {
cursor = "> "
style = selectedItemStyle
}
b.WriteString(style.Render(cursor + item))
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("Use arrow keys or j/k to navigate, Enter to select, Esc to cancel"))
return appStyle.Render(b.String())
}
// renderAPIKeyInput renders the API key input view
func (m Model) renderAPIKeyInput() string {
var b strings.Builder
provider := m.providers[m.listIndex]
b.WriteString(headerStyle.Render(fmt.Sprintf("Enter API Key for %s", provider.Name)))
b.WriteString("\n\n")
b.WriteString(fmt.Sprintf("Environment variable: %s\n\n", provider.EnvVar))
b.WriteString("Enter your API key below (it will be hidden):\n\n")
inputBox := inputStyle.Render(m.apiKeyInput.View())
b.WriteString(inputBox)
b.WriteString("\n\n")
b.WriteString(helpStyle.Render("Enter to confirm, Esc to cancel"))
return appStyle.Render(b.String())
}
// renderModelSelect renders the model selection view
func (m Model) renderModelSelect() string {
var b strings.Builder
b.WriteString(headerStyle.Render(fmt.Sprintf("Select Model (%s)", m.providerName)))
b.WriteString("\n\n")
for i, item := range m.listItems {
cursor := " "
style := normalItemStyle
if i == m.listIndex {
cursor = "> "
style = selectedItemStyle
}
if item == m.modelName {
item += " (current)"
}
b.WriteString(style.Render(cursor + item))
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("Use arrow keys or j/k to navigate, Enter to select, Esc to cancel"))
return appStyle.Render(b.String())
}
// renderImageInput renders the image input view
func (m Model) renderImageInput() string {
var b strings.Builder
b.WriteString(headerStyle.Render("Add Image"))
b.WriteString("\n\n")
b.WriteString("Enter an image source:\n")
b.WriteString(" - File path (e.g., /path/to/image.png)\n")
b.WriteString(" - URL (e.g., https://example.com/image.jpg)\n")
b.WriteString(" - Base64 data or data URL\n\n")
if len(m.pendingImages) > 0 {
b.WriteString(imageIndicatorStyle.Render(fmt.Sprintf("Currently attached: %d image(s)\n\n", len(m.pendingImages))))
}
inputBox := inputStyle.Render(m.input.View())
b.WriteString(inputBox)
b.WriteString("\n\n")
b.WriteString(helpStyle.Render("Enter to add image, Esc to cancel"))
return appStyle.Render(b.String())
}
// renderToolsPanel renders the tools panel
func (m Model) renderToolsPanel() string {
var b strings.Builder
b.WriteString(headerStyle.Render("Tools / Function Calling"))
b.WriteString("\n\n")
status := "DISABLED"
statusStyle := errorStyle
if m.toolsEnabled {
status = "ENABLED"
statusStyle = lipgloss.NewStyle().Foreground(successColor).Bold(true)
}
b.WriteString(settingLabelStyle.Render("Tools Status:"))
b.WriteString(statusStyle.Render(status))
b.WriteString("\n\n")
b.WriteString("Available tools:\n")
for _, fn := range m.toolbox.Functions() {
b.WriteString(fmt.Sprintf(" - %s: %s\n", selectedItemStyle.Render(fn.Name), fn.Description))
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("Press 't' to toggle tools, Enter or 'q' to close"))
return appStyle.Render(b.String())
}
// renderSettings renders the settings view
func (m Model) renderSettings() string {
var b strings.Builder
b.WriteString(headerStyle.Render("Settings"))
b.WriteString("\n\n")
// Temperature
tempStr := "default"
if m.temperature != nil {
tempStr = fmt.Sprintf("%.1f", *m.temperature)
}
b.WriteString(settingLabelStyle.Render("Temperature:"))
b.WriteString(settingValueStyle.Render(tempStr))
b.WriteString("\n\n")
b.WriteString("Press a key to set temperature:\n")
b.WriteString(" 1 - Default (model decides)\n")
b.WriteString(" 2 - 0.0 (deterministic)\n")
b.WriteString(" 3 - 0.5 (balanced)\n")
b.WriteString(" 4 - 0.7 (creative)\n")
b.WriteString(" 5 - 1.0 (very creative)\n")
b.WriteString("\n")
// System prompt
b.WriteString(settingLabelStyle.Render("System Prompt:"))
b.WriteString("\n")
b.WriteString(settingValueStyle.Render(" " + m.systemPrompt))
b.WriteString("\n\n")
b.WriteString(helpStyle.Render("Enter or 'q' to close"))
return appStyle.Render(b.String())
}

120
context.go Normal file
View File

@@ -0,0 +1,120 @@
package llm
import (
"context"
"time"
)
type Context struct {
context.Context
request Request
response *ResponseChoice
toolcall *ToolCall
syntheticFields map[string]string
}
func (c *Context) ToNewRequest(toolResults ...ToolCallResponse) Request {
var res Request
res.Toolbox = c.request.Toolbox
res.Temperature = c.request.Temperature
res.Conversation = make([]Input, len(c.request.Conversation))
copy(res.Conversation, c.request.Conversation)
// now for every input message, convert those to an Input to add to the conversation
for _, msg := range c.request.Messages {
res.Conversation = append(res.Conversation, msg)
}
// if there are tool calls, then we need to add those to the conversation
if c.response != nil {
res.Conversation = append(res.Conversation, *c.response)
}
// if there are tool results, then we need to add those to the conversation
for _, result := range toolResults {
res.Conversation = append(res.Conversation, result)
}
return res
}
func NewContext(ctx context.Context, request Request, response *ResponseChoice, toolcall *ToolCall) *Context {
return &Context{Context: ctx, request: request, response: response, toolcall: toolcall}
}
func (c *Context) Request() Request {
return c.request
}
func (c *Context) Response() *ResponseChoice {
return c.response
}
func (c *Context) ToolCall() *ToolCall {
return c.toolcall
}
func (c *Context) SyntheticFields() map[string]string {
if c.syntheticFields == nil {
c.syntheticFields = map[string]string{}
}
return c.syntheticFields
}
func (c *Context) WithContext(ctx context.Context) *Context {
return &Context{Context: ctx, request: c.request, response: c.response, toolcall: c.toolcall, syntheticFields: c.syntheticFields}
}
func (c *Context) WithRequest(request Request) *Context {
return &Context{Context: c.Context, request: request, response: c.response, toolcall: c.toolcall, syntheticFields: c.syntheticFields}
}
func (c *Context) WithResponse(response *ResponseChoice) *Context {
return &Context{Context: c.Context, request: c.request, response: response, toolcall: c.toolcall, syntheticFields: c.syntheticFields}
}
func (c *Context) WithToolCall(toolcall *ToolCall) *Context {
return &Context{Context: c.Context, request: c.request, response: c.response, toolcall: toolcall, syntheticFields: c.syntheticFields}
}
func (c *Context) WithSyntheticFields(syntheticFields map[string]string) *Context {
return &Context{Context: c.Context, request: c.request, response: c.response, toolcall: c.toolcall, syntheticFields: syntheticFields}
}
func (c *Context) Deadline() (deadline time.Time, ok bool) {
return c.Context.Deadline()
}
func (c *Context) Done() <-chan struct{} {
return c.Context.Done()
}
func (c *Context) Err() error {
return c.Context.Err()
}
func (c *Context) Value(key any) any {
switch key {
case "request":
return c.request
case "response":
return c.response
case "toolcall":
return c.toolcall
case "syntheticFields":
return c.syntheticFields
}
return c.Context.Value(key)
}
func (c *Context) WithTimeout(timeout time.Duration) (*Context, context.CancelFunc) {
ctx, cancel := context.WithTimeout(c.Context, timeout)
return c.WithContext(ctx), cancel
}

View File

@@ -1,4 +1,4 @@
package go_llm
package llm
import "fmt"

View File

@@ -1,14 +1,14 @@
package go_llm
package llm
import (
"context"
"encoding/json"
"fmt"
"gitea.stevedudenhoeffer.com/steve/go-llm/schema"
"github.com/sashabaranov/go-openai"
"github.com/sashabaranov/go-openai/jsonschema"
"log/slog"
"reflect"
"time"
"gitea.stevedudenhoeffer.com/steve/go-llm/schema"
)
type Function struct {
@@ -26,27 +26,76 @@ type Function struct {
fn reflect.Value
paramType reflect.Type
// definition is a cache of the openaiImpl jsonschema definition
definition *jsonschema.Definition
}
func (f *Function) Execute(ctx context.Context, input string) (string, error) {
func (f Function) WithSyntheticField(name string, description string) Function {
if obj, o := f.Parameters.(schema.Object); o {
f.Parameters = obj.WithSyntheticField(name, description)
}
return f
}
func (f Function) WithSyntheticFields(fieldsAndDescriptions map[string]string) Function {
if obj, o := f.Parameters.(schema.Object); o {
for k, v := range fieldsAndDescriptions {
obj = obj.WithSyntheticField(k, v)
}
f.Parameters = obj
}
return f
}
func (f Function) WithDescription(description string) Function {
f.Description = description
return f
}
func (f Function) Execute(ctx *Context, input string) (any, error) {
if !f.fn.IsValid() {
return "", fmt.Errorf("function %s is not implemented", f.Name)
}
slog.Info("Function.Execute", "name", f.Name, "input", input, "f", f.paramType)
// first, we need to parse the input into the struct
p := reflect.New(f.paramType)
fmt.Println("Function.Execute", f.Name, "input:", input)
//m := map[string]any{}
err := json.Unmarshal([]byte(input), p.Interface())
var vals map[string]any
err := json.Unmarshal([]byte(input), &vals)
var syntheticFields map[string]string
// first eat up any synthetic fields
if obj, o := f.Parameters.(schema.Object); o {
for k := range obj.SyntheticFields() {
key := schema.SyntheticFieldPrefix + k
if val, ok := vals[key]; ok {
if syntheticFields == nil {
syntheticFields = map[string]string{}
}
syntheticFields[k] = fmt.Sprint(val)
delete(vals, key)
}
}
}
// now for any remaining fields, re-marshal them into json and then unmarshal into the struct
b, err := json.Marshal(vals)
if err != nil {
return "", fmt.Errorf("failed to marshal input: %w (input: %s)", err, input)
}
// now we can unmarshal the input into the struct
err = json.Unmarshal(b, p.Interface())
if err != nil {
return "", fmt.Errorf("failed to unmarshal input: %w (input: %s)", err, input)
}
// now we can call the function
exec := func(ctx context.Context) (string, error) {
exec := func(ctx *Context) (any, error) {
out := f.fn.Call([]reflect.Value{reflect.ValueOf(ctx), p.Elem()})
if len(out) != 2 {
@@ -54,7 +103,7 @@ func (f *Function) Execute(ctx context.Context, input string) (string, error) {
}
if out[1].IsNil() {
return out[0].String(), nil
return out[0].Interface(), nil
}
return "", out[1].Interface().(error)
@@ -62,31 +111,26 @@ func (f *Function) Execute(ctx context.Context, input string) (string, error) {
var cancel context.CancelFunc
if f.Timeout > 0 {
ctx, cancel = context.WithTimeout(ctx, f.Timeout)
ctx, cancel = ctx.WithTimeout(f.Timeout)
defer cancel()
}
return exec(ctx)
}
func (f *Function) toOpenAIFunction() *openai.FunctionDefinition {
return &openai.FunctionDefinition{
Name: f.Name,
Description: f.Description,
Strict: f.Strict,
Parameters: f.Parameters,
}
}
func (f *Function) toOpenAIDefinition() jsonschema.Definition {
if f.definition == nil {
def := f.Parameters.Definition()
f.definition = &def
}
return *f.definition
}
type FunctionCall struct {
Name string `json:"name,omitempty"`
Arguments string `json:"arguments,omitempty"`
}
func (fc *FunctionCall) toRaw() map[string]any {
res := map[string]interface{}{
"name": fc.Name,
}
if fc.Arguments != "" {
res["arguments"] = fc.Arguments
}
return res
}

View File

@@ -1,9 +1,9 @@
package go_llm
package llm
import (
"context"
"gitea.stevedudenhoeffer.com/steve/go-llm/schema"
"reflect"
"gitea.stevedudenhoeffer.com/steve/go-llm/schema"
)
// Parse takes a function pointer and returns a function object.
@@ -13,7 +13,7 @@ import (
// The struct parameters can have the following tags:
// - Description: a string that describes the parameter, passed to openaiImpl to tell it what the parameter is for
func NewFunction[T any](name string, description string, fn func(context.Context, T) (string, error)) *Function {
func NewFunction[T any](name string, description string, fn func(*Context, T) (any, error)) Function {
var o T
res := Function{
@@ -31,5 +31,5 @@ func NewFunction[T any](name string, description string, fn func(context.Context
panic("function parameter must be a struct")
}
return &res
return res
}

91
go.mod
View File

@@ -1,44 +1,67 @@
module gitea.stevedudenhoeffer.com/steve/go-llm
go 1.23.1
go 1.24.0
toolchain go1.24.2
require (
github.com/google/generative-ai-go v0.19.0
github.com/liushuangls/go-anthropic/v2 v2.13.0
github.com/sashabaranov/go-openai v1.36.1
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67
google.golang.org/api v0.214.0
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/joho/godotenv v1.5.1
github.com/liushuangls/go-anthropic/v2 v2.17.0
github.com/modelcontextprotocol/go-sdk v1.2.0
github.com/openai/openai-go v1.12.0
golang.org/x/image v0.35.0
google.golang.org/genai v1.43.0
)
require (
cloud.google.com/go v0.117.0 // indirect
cloud.google.com/go/ai v0.9.0 // indirect
cloud.google.com/go/auth v0.13.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
cloud.google.com/go/compute/metadata v0.6.0 // indirect
cloud.google.com/go/longrunning v0.6.3 // indirect
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.18.1 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/s2a-go v0.1.8 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect
go.opentelemetry.io/otel v1.33.0 // indirect
go.opentelemetry.io/otel/metric v1.33.0 // indirect
go.opentelemetry.io/otel/trace v1.33.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/oauth2 v0.24.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/time v0.8.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20241223144023-3abc09e42ca8 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 // indirect
google.golang.org/grpc v1.69.2 // indirect
google.golang.org/protobuf v1.36.1 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/jsonschema-go v0.3.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
github.com/googleapis/gax-go/v2 v2.16.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/oauth2 v0.32.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d // indirect
google.golang.org/grpc v1.78.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

204
go.sum
View File

@@ -1,89 +1,145 @@
cloud.google.com/go v0.117.0 h1:Z5TNFfQxj7WG2FgOGX1ekC5RiXrYgms6QscOm32M/4s=
cloud.google.com/go v0.117.0/go.mod h1:ZbwhVTb1DBGt2Iwb3tNO6SEK4q+cplHZmLWH+DelYYc=
cloud.google.com/go/ai v0.9.0 h1:r1Ig8O8+Qr3Ia3WfoO+gokD0fxB2Rk4quppuKjmGMsY=
cloud.google.com/go/ai v0.9.0/go.mod h1:28bKM/oxmRgxmRgI1GLumFv+NSkt+DscAg/gF+54zzY=
cloud.google.com/go/auth v0.13.0 h1:8Fu8TZy167JkW8Tj3q7dIkr2v4cndv41ouecJx0PAHs=
cloud.google.com/go/auth v0.13.0/go.mod h1:COOjD9gwfKNKz+IIduatIhYJQIc0mG3H102r/EMxX6Q=
cloud.google.com/go/auth/oauth2adapt v0.2.6 h1:V6a6XDu2lTwPZWOawrAa9HUK+DB2zfJyTuciBG5hFkU=
cloud.google.com/go/auth/oauth2adapt v0.2.6/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8=
cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
cloud.google.com/go/longrunning v0.6.3 h1:A2q2vuyXysRcwzqDpMMLSI6mb6o39miS52UEG/Rd2ng=
cloud.google.com/go/longrunning v0.6.3/go.mod h1:k/vIs83RN4bE3YCswdXC5PFfWVILjm3hpEUlSko4PiI=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs=
cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVfg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/generative-ai-go v0.19.0 h1:R71szggh8wHMCUlEMsW2A/3T+5LdEIkiaHSYgSpUgdg=
github.com/google/generative-ai-go v0.19.0/go.mod h1:JYolL13VG7j79kM5BtHz4qwONHkeJQzOCkKXnpqtS/E=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
github.com/liushuangls/go-anthropic/v2 v2.13.0 h1:f7KJ54IHxIpHPPhrCzs3SrdP2PfErXiJcJn7DUVstSA=
github.com/liushuangls/go-anthropic/v2 v2.13.0/go.mod h1:5ZwRLF5TQ+y5s/MC9Z1IJYx9WUFgQCKfqFM2xreIQLk=
github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao=
github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y=
github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/liushuangls/go-anthropic/v2 v2.17.0 h1:iBA6h7aghi1q86owEQ95XE2R2MF/0dQ7bCxtwTxOg4c=
github.com/liushuangls/go-anthropic/v2 v2.17.0/go.mod h1:a550cJXPoTG2FL3DvfKG2zzD5O2vjgvo4tHtoGPzFLU=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s=
github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0=
github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sashabaranov/go-openai v1.36.0 h1:fcSrn8uGuorzPWCBp8L0aCR95Zjb/Dd+ZSML0YZy9EI=
github.com/sashabaranov/go-openai v1.36.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/sashabaranov/go-openai v1.36.1 h1:EVfRXwIlW2rUzpx6vR+aeIKCK/xylSrVYAx1TMTSX3g=
github.com/sashabaranov/go-openai v1.36.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0 h1:PS8wXpbyaDJQ2VDHHncMe9Vct0Zn1fEjpsjrLxGJoSc=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0/go.mod h1:HDBUsEjOuRC0EzKZ1bSaRGZWUBAzo+MhAcUUORSr4D0=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q=
go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw=
go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I=
go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ=
go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M=
go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk=
go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0=
go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc=
go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8=
go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s=
go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 h1:1UoZQm6f0P/ZO0w1Ri+f+ifG/gXhegadRdwBIXEFWDo=
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
google.golang.org/api v0.214.0 h1:h2Gkq07OYi6kusGOaT/9rnNljuXmqPnaig7WGPmKbwA=
google.golang.org/api v0.214.0/go.mod h1:bYPpLG8AyeMWwDU6NXoB00xC0DFkikVvd5MfwoxjLqE=
google.golang.org/genproto/googleapis/api v0.0.0-20241223144023-3abc09e42ca8 h1:st3LcW/BPi75W4q1jJTEor/QWwbNlPlDG0JTn6XhZu0=
google.golang.org/genproto/googleapis/api v0.0.0-20241223144023-3abc09e42ca8/go.mod h1:klhJGKFyG8Tn50enBn7gizg4nXGXJ+jqEREdCWaPcV4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 h1:TqExAhdPaB60Ux47Cn0oLV07rGnxZzIsaRhQaqS666A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8/go.mod h1:lcTa1sDdWEIHMWlITnIczmw5w60CF9ffkb8Z+DVmmjA=
google.golang.org/grpc v1.69.2 h1:U3S9QEtbXC0bYNvRtcoklF3xGtLViumSYxWykJS+7AU=
google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4=
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genai v1.43.0 h1:8vhqhzJNZu1U94e2m+KvDq/TUUjSmDrs1aKkvTa8SoM=
google.golang.org/genai v1.43.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d h1:xXzuihhT3gL/ntduUZwHECzAn57E8dA6l8SOtYWdD8Q=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

150
google.go
View File

@@ -1,71 +1,122 @@
package go_llm
package llm
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"github.com/google/generative-ai-go/genai"
"google.golang.org/api/option"
"io"
"net/http"
"google.golang.org/genai"
)
type google struct {
type googleImpl struct {
key string
model string
}
func (g google) ModelVersion(modelVersion string) (ChatCompletion, error) {
var _ LLM = googleImpl{}
func (g googleImpl) ModelVersion(modelVersion string) (ChatCompletion, error) {
g.model = modelVersion
return g, nil
}
func (g google) requestToGoogleRequest(in Request, model *genai.GenerativeModel) []genai.Part {
func (g googleImpl) requestToContents(in Request) ([]*genai.Content, *genai.GenerateContentConfig) {
var contents []*genai.Content
var cfg genai.GenerateContentConfig
if in.Temperature != nil {
model.GenerationConfig.Temperature = in.Temperature
for _, tool := range in.Toolbox.Functions() {
cfg.Tools = append(cfg.Tools, &genai.Tool{
FunctionDeclarations: []*genai.FunctionDeclaration{
{
Name: tool.Name,
Description: tool.Description,
Parameters: tool.Parameters.GoogleParameters(),
},
},
})
}
res := []genai.Part{}
if in.Toolbox.RequiresTool() {
cfg.ToolConfig = &genai.ToolConfig{FunctionCallingConfig: &genai.FunctionCallingConfig{
Mode: genai.FunctionCallingConfigModeAny,
}}
}
for _, c := range in.Messages {
res = append(res, genai.Text(c.Text))
var role genai.Role
switch c.Role {
case RoleAssistant, RoleSystem:
role = genai.RoleModel
case RoleUser:
role = genai.RoleUser
}
var parts []*genai.Part
if c.Text != "" {
parts = append(parts, genai.NewPartFromText(c.Text))
}
for _, img := range c.Images {
if img.Url != "" {
// gemini does not support URLs, so we need to download the image and convert it to a blob
resp, err := http.Get(img.Url)
if err != nil {
panic(fmt.Sprintf("error downloading image: %v", err))
}
defer resp.Body.Close()
if resp.ContentLength > 20*1024*1024 {
panic(fmt.Sprintf("image size exceeds 20MB: %d bytes", resp.ContentLength))
}
data, err := io.ReadAll(resp.Body)
if err != nil {
panic(fmt.Sprintf("error reading image data: %v", err))
}
mimeType := http.DetectContentType(data)
switch mimeType {
case "image/jpeg", "image/png", "image/gif":
// MIME type is valid
default:
panic(fmt.Sprintf("unsupported image MIME type: %s", mimeType))
}
parts = append(parts, genai.NewPartFromBytes(data, mimeType))
} else {
b, e := base64.StdEncoding.DecodeString(img.Base64)
if e != nil {
panic(fmt.Sprintf("error decoding base64: %v", e))
}
parts = append(parts, genai.NewPartFromBytes(b, img.ContentType))
}
}
contents = append(contents, genai.NewContentFromParts(parts, role))
}
for _, tool := range in.Toolbox.funcs {
panic("google ToolBox is todo" + tool.Name)
/*
t := genai.Tool{}
t.FunctionDeclarations = append(t.FunctionDeclarations, &genai.FunctionDeclaration{
Name: tool.Name,
Description: tool.Description,
Parameters: nil, //tool.Parameters,
})
*/
}
return res
return contents, &cfg
}
func (g google) responseToLLMResponse(in *genai.GenerateContentResponse) (Response, error) {
func (g googleImpl) responseToLLMResponse(in *genai.GenerateContentResponse) (Response, error) {
res := Response{}
for _, c := range in.Candidates {
var choice ResponseChoice
var set = false
if c.Content != nil {
for _, p := range c.Content.Parts {
switch p.(type) {
case genai.Text:
res.Choices = append(res.Choices, ResponseChoice{
Content: string(p.(genai.Text)),
})
case genai.FunctionCall:
v := p.(genai.FunctionCall)
choice := ResponseChoice{}
choice.Content = v.Name
if p.Text != "" {
set = true
choice.Content = p.Text
} else if p.FunctionCall != nil {
v := p.FunctionCall
b, e := json.Marshal(v.Args)
if e != nil {
return Response{}, fmt.Errorf("error marshalling args: %w", e)
}
@@ -79,32 +130,33 @@ func (g google) responseToLLMResponse(in *genai.GenerateContentResponse) (Respon
}
choice.Calls = append(choice.Calls, call)
res.Choices = append(res.Choices, choice)
default:
return Response{}, fmt.Errorf("unknown part type: %T", p)
set = true
}
}
}
if set {
choice.Role = RoleAssistant
res.Choices = append(res.Choices, choice)
}
}
return res, nil
}
func (g google) ChatComplete(ctx context.Context, req Request) (Response, error) {
cl, err := genai.NewClient(ctx, option.WithAPIKey(g.key))
func (g googleImpl) ChatComplete(ctx context.Context, req Request) (Response, error) {
cl, err := genai.NewClient(ctx, &genai.ClientConfig{
APIKey: g.key,
Backend: genai.BackendGeminiAPI,
})
if err != nil {
return Response{}, fmt.Errorf("error creating genai client: %w", err)
}
model := cl.GenerativeModel(g.model)
parts := g.requestToGoogleRequest(req, model)
resp, err := model.GenerateContent(ctx, parts...)
contents, cfg := g.requestToContents(req)
resp, err := cl.Models.GenerateContent(ctx, g.model, contents, cfg)
if err != nil {
return Response{}, fmt.Errorf("error generating content: %w", err)
}

View File

@@ -0,0 +1,114 @@
package imageutil
import (
"bytes"
"encoding/base64"
"fmt"
"image"
"image/gif"
"image/jpeg"
"net/http"
"golang.org/x/image/draw"
)
// CompressImage takes a base-64-encoded image (JPEG, PNG or GIF) and returns
// a base-64-encoded version that is at most maxLength in size, or an error.
func CompressImage(b64 string, maxLength int) (string, string, error) {
raw, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return "", "", fmt.Errorf("base64 decode: %w", err)
}
mime := http.DetectContentType(raw)
if len(raw) <= maxLength {
return b64, mime, nil // small enough already
}
switch mime {
case "image/gif":
return compressGIF(raw, maxLength)
default: // jpeg, png, webp, etc. -> treat as raster
return compressRaster(raw, maxLength)
}
}
// ---------- Raster path (jpeg / png / single-frame gif) ----------
func compressRaster(src []byte, maxLength int) (string, string, error) {
img, _, err := image.Decode(bytes.NewReader(src))
if err != nil {
return "", "", fmt.Errorf("decode raster: %w", err)
}
quality := 95
for {
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err != nil {
return "", "", fmt.Errorf("jpeg encode: %w", err)
}
if buf.Len() <= maxLength {
return base64.StdEncoding.EncodeToString(buf.Bytes()), "image/jpeg", nil
}
if quality > 20 {
quality -= 5
continue
}
// down-scale 80%
b := img.Bounds()
if b.Dx() < 100 || b.Dy() < 100 {
return "", "", fmt.Errorf("cannot compress below %.02fMiB without destroying image", float64(maxLength)/1048576.0)
}
dst := image.NewRGBA(image.Rect(0, 0, int(float64(b.Dx())*0.8), int(float64(b.Dy())*0.8)))
draw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
img = dst
quality = 95 // restart ladder
}
}
// ---------- Animated GIF path ----------
func compressGIF(src []byte, maxLength int) (string, string, error) {
g, err := gif.DecodeAll(bytes.NewReader(src))
if err != nil {
return "", "", fmt.Errorf("gif decode: %w", err)
}
for {
var buf bytes.Buffer
if err := gif.EncodeAll(&buf, g); err != nil {
return "", "", fmt.Errorf("gif encode: %w", err)
}
if buf.Len() <= maxLength {
return base64.StdEncoding.EncodeToString(buf.Bytes()), "image/gif", nil
}
// down-scale every frame by 80%
w, h := g.Config.Width, g.Config.Height
if w < 100 || h < 100 {
return "", "", fmt.Errorf("cannot compress animated GIF below 5 MiB without excessive quality loss")
}
nw, nh := int(float64(w)*0.8), int(float64(h)*0.8)
for i, frm := range g.Image {
// convert paletted frame -> RGBA for scaling
rgba := image.NewRGBA(frm.Bounds())
draw.Draw(rgba, rgba.Bounds(), frm, frm.Bounds().Min, draw.Src)
// scaled destination
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
draw.ApproxBiLinear.Scale(dst, dst.Bounds(), rgba, rgba.Bounds(), draw.Over, nil)
// quantize back to paletted using default encoder quantizer
paletted := image.NewPaletted(dst.Bounds(), nil)
draw.FloydSteinberg.Draw(paletted, paletted.Bounds(), dst, dst.Bounds().Min)
g.Image[i] = paletted
}
g.Config.Width, g.Config.Height = nw, nh
// loop back and test size again ...
}
}

55
llm.go
View File

@@ -1,69 +1,30 @@
package go_llm
package llm
import (
"context"
)
type Role string
const (
RoleSystem Role = "system"
RoleUser Role = "user"
RoleAssistant Role = "assistant"
)
type Image struct {
Base64 string
ContentType string
Url string
}
type Message struct {
Role Role
Name string
Text string
Images []Image
}
type Request struct {
Messages []Message
Toolbox *ToolBox
Temperature *float32
}
type ToolCall struct {
ID string
FunctionCall FunctionCall
}
type ResponseChoice struct {
Index int
Role Role
Content string
Refusal string
Name string
Calls []ToolCall
}
type Response struct {
Choices []ResponseChoice
}
// ChatCompletion is the interface for chat completion.
type ChatCompletion interface {
ChatComplete(ctx context.Context, req Request) (Response, error)
}
// LLM is the interface for language model providers.
type LLM interface {
ModelVersion(modelVersion string) (ChatCompletion, error)
}
// OpenAI creates a new OpenAI LLM provider with the given API key.
func OpenAI(key string) LLM {
return openaiImpl{key: key}
}
// Anthropic creates a new Anthropic LLM provider with the given API key.
func Anthropic(key string) LLM {
return anthropic{key: key}
return anthropicImpl{key: key}
}
// Google creates a new Google LLM provider with the given API key.
func Google(key string) LLM {
return google{key: key}
return googleImpl{key: key}
}

238
mcp.go Normal file
View File

@@ -0,0 +1,238 @@
package llm
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"sync"
"github.com/modelcontextprotocol/go-sdk/mcp"
"gitea.stevedudenhoeffer.com/steve/go-llm/schema"
)
// MCPServer represents a connection to an MCP server.
// It manages the lifecycle of the connection and provides access to the server's tools.
type MCPServer struct {
// Name is a friendly name for this server (used for logging/identification)
Name string
// Command is the command to run the MCP server (for stdio transport)
Command string
// Args are arguments to pass to the command
Args []string
// Env are environment variables to set for the command (in addition to current environment)
Env []string
// URL is the URL for SSE or HTTP transport (alternative to Command)
URL string
// Transport specifies the transport type: "stdio" (default), "sse", or "http"
Transport string
client *mcp.Client
session *mcp.ClientSession
tools map[string]*mcp.Tool // tool name -> tool definition
mu sync.RWMutex
}
// Connect establishes a connection to the MCP server.
func (m *MCPServer) Connect(ctx context.Context) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.session != nil {
return nil // Already connected
}
m.client = mcp.NewClient(&mcp.Implementation{
Name: "go-llm",
Version: "1.0.0",
}, nil)
var transport mcp.Transport
switch m.Transport {
case "sse":
transport = &mcp.SSEClientTransport{
Endpoint: m.URL,
}
case "http":
transport = &mcp.StreamableClientTransport{
Endpoint: m.URL,
}
default: // "stdio" or empty
cmd := exec.Command(m.Command, m.Args...)
cmd.Env = append(os.Environ(), m.Env...)
transport = &mcp.CommandTransport{
Command: cmd,
}
}
session, err := m.client.Connect(ctx, transport, nil)
if err != nil {
return fmt.Errorf("failed to connect to MCP server %s: %w", m.Name, err)
}
m.session = session
// Load tools
m.tools = make(map[string]*mcp.Tool)
for tool, err := range session.Tools(ctx, nil) {
if err != nil {
m.session.Close()
m.session = nil
return fmt.Errorf("failed to list tools from %s: %w", m.Name, err)
}
m.tools[tool.Name] = tool
}
return nil
}
// Close closes the connection to the MCP server.
func (m *MCPServer) Close() error {
m.mu.Lock()
defer m.mu.Unlock()
if m.session == nil {
return nil
}
err := m.session.Close()
m.session = nil
m.tools = nil
return err
}
// IsConnected returns true if the server is connected.
func (m *MCPServer) IsConnected() bool {
m.mu.RLock()
defer m.mu.RUnlock()
return m.session != nil
}
// Tools returns the list of tool names available from this server.
func (m *MCPServer) Tools() []string {
m.mu.RLock()
defer m.mu.RUnlock()
var names []string
for name := range m.tools {
names = append(names, name)
}
return names
}
// HasTool returns true if this server provides the named tool.
func (m *MCPServer) HasTool(name string) bool {
m.mu.RLock()
defer m.mu.RUnlock()
_, ok := m.tools[name]
return ok
}
// CallTool calls a tool on the MCP server.
func (m *MCPServer) CallTool(ctx context.Context, name string, arguments map[string]any) (any, error) {
m.mu.RLock()
session := m.session
m.mu.RUnlock()
if session == nil {
return nil, fmt.Errorf("not connected to MCP server %s", m.Name)
}
result, err := session.CallTool(ctx, &mcp.CallToolParams{
Name: name,
Arguments: arguments,
})
if err != nil {
return nil, err
}
// Process the result content
if len(result.Content) == 0 {
return nil, nil
}
// If there's a single text content, return it as a string
if len(result.Content) == 1 {
if textContent, ok := result.Content[0].(*mcp.TextContent); ok {
return textContent.Text, nil
}
}
// For multiple contents or non-text, serialize to string
return contentToString(result.Content), nil
}
// toFunction converts an MCP tool to a go-llm Function (for schema purposes only).
func (m *MCPServer) toFunction(tool *mcp.Tool) Function {
var inputSchema map[string]any
if tool.InputSchema != nil {
data, err := json.Marshal(tool.InputSchema)
if err == nil {
_ = json.Unmarshal(data, &inputSchema)
}
}
if inputSchema == nil {
inputSchema = map[string]any{
"type": "object",
"properties": map[string]any{},
}
}
return Function{
Name: tool.Name,
Description: tool.Description,
Parameters: schema.NewRaw(inputSchema),
}
}
// contentToString converts MCP content to a string representation.
func contentToString(content []mcp.Content) string {
var parts []string
for _, c := range content {
switch tc := c.(type) {
case *mcp.TextContent:
parts = append(parts, tc.Text)
default:
if data, err := json.Marshal(c); err == nil {
parts = append(parts, string(data))
}
}
}
if len(parts) == 1 {
return parts[0]
}
data, _ := json.Marshal(parts)
return string(data)
}
// WithMCPServer adds an MCP server to the toolbox.
// The server must already be connected. Tools from the server will be available
// for use, and tool calls will be routed to the appropriate server.
func (t ToolBox) WithMCPServer(server *MCPServer) ToolBox {
if t.mcpServers == nil {
t.mcpServers = make(map[string]*MCPServer)
}
server.mu.RLock()
defer server.mu.RUnlock()
for name, tool := range server.tools {
// Add the function definition (for schema)
fn := server.toFunction(tool)
t.functions[name] = fn
// Track which server owns this tool
t.mcpServers[name] = server
}
return t
}

115
message.go Normal file
View File

@@ -0,0 +1,115 @@
package llm
// Role represents the role of a message in a conversation.
type Role string
const (
RoleSystem Role = "system"
RoleUser Role = "user"
RoleAssistant Role = "assistant"
)
// Image represents an image that can be included in a message.
type Image struct {
Base64 string
ContentType string
Url string
}
func (i Image) toRaw() map[string]any {
res := map[string]any{
"base64": i.Base64,
"contenttype": i.ContentType,
"url": i.Url,
}
return res
}
func (i *Image) fromRaw(raw map[string]any) Image {
var res Image
res.Base64 = raw["base64"].(string)
res.ContentType = raw["contenttype"].(string)
res.Url = raw["url"].(string)
return res
}
// Message represents a message in a conversation.
type Message struct {
Role Role
Name string
Text string
Images []Image
}
func (m Message) toRaw() map[string]any {
res := map[string]any{
"role": m.Role,
"name": m.Name,
"text": m.Text,
}
images := make([]map[string]any, 0, len(m.Images))
for _, img := range m.Images {
images = append(images, img.toRaw())
}
res["images"] = images
return res
}
func (m *Message) fromRaw(raw map[string]any) Message {
var res Message
res.Role = Role(raw["role"].(string))
res.Name = raw["name"].(string)
res.Text = raw["text"].(string)
images := raw["images"].([]map[string]any)
for _, img := range images {
var i Image
res.Images = append(res.Images, i.fromRaw(img))
}
return res
}
// ToolCall represents a tool call made by an assistant.
type ToolCall struct {
ID string
FunctionCall FunctionCall
}
func (t ToolCall) toRaw() map[string]any {
res := map[string]any{
"id": t.ID,
}
res["function"] = t.FunctionCall.toRaw()
return res
}
// ToolCallResponse represents the response to a tool call.
type ToolCallResponse struct {
ID string
Result any
Error error
}
func (t ToolCallResponse) toRaw() map[string]any {
res := map[string]any{
"id": t.ID,
"result": t.Result,
}
if t.Error != nil {
res["error"] = t.Error.Error()
}
return res
}

335
openai.go
View File

@@ -1,124 +1,95 @@
package go_llm
package llm
import (
"context"
"fmt"
"strings"
oai "github.com/sashabaranov/go-openai"
"github.com/openai/openai-go"
"github.com/openai/openai-go/option"
"github.com/openai/openai-go/packages/param"
"github.com/openai/openai-go/shared"
)
type openaiImpl struct {
key string
model string
key string
model string
baseUrl string
}
var _ LLM = openaiImpl{}
func (o openaiImpl) requestToOpenAIRequest(request Request) oai.ChatCompletionRequest {
res := oai.ChatCompletionRequest{
func (o openaiImpl) newRequestToOpenAIRequest(request Request) openai.ChatCompletionNewParams {
res := openai.ChatCompletionNewParams{
Model: o.model,
}
for _, msg := range request.Messages {
m := oai.ChatCompletionMessage{
Content: msg.Text,
Role: string(msg.Role),
Name: msg.Name,
}
for _, img := range msg.Images {
if img.Base64 != "" {
m.MultiContent = append(m.MultiContent, oai.ChatMessagePart{
Type: "image_url",
ImageURL: &oai.ChatMessageImageURL{
URL: fmt.Sprintf("data:%s;base64,%s", img.ContentType, img.Base64),
},
})
} else if img.Url != "" {
m.MultiContent = append(m.MultiContent, oai.ChatMessagePart{
Type: "image_url",
ImageURL: &oai.ChatMessageImageURL{
URL: img.Url,
},
})
}
}
// openai does not allow Content and MultiContent to be set at the same time, so we need to check
if len(m.MultiContent) > 0 && m.Content != "" {
m.MultiContent = append([]oai.ChatMessagePart{{
Type: "text",
Text: m.Content,
}}, m.MultiContent...)
m.Content = ""
}
res.Messages = append(res.Messages, m)
for _, i := range request.Conversation {
res.Messages = append(res.Messages, inputToChatCompletionMessages(i, o.model)...)
}
if request.Toolbox != nil {
for _, tool := range request.Toolbox.funcs {
res.Tools = append(res.Tools, oai.Tool{
Type: "function",
Function: &oai.FunctionDefinition{
Name: tool.Name,
Description: tool.Description,
Strict: tool.Strict,
Parameters: tool.Parameters.Definition(),
},
})
for _, msg := range request.Messages {
res.Messages = append(res.Messages, messageToChatCompletionMessages(msg, o.model)...)
}
fmt.Println("tool:", tool.Name, tool.Description, tool.Strict, tool.Parameters.Definition())
for _, tool := range request.Toolbox.Functions() {
res.Tools = append(res.Tools, openai.ChatCompletionToolParam{
Type: "function",
Function: shared.FunctionDefinitionParam{
Name: tool.Name,
Description: openai.String(tool.Description),
Strict: openai.Bool(tool.Strict),
Parameters: tool.Parameters.OpenAIParameters(),
},
})
}
if request.Toolbox.RequiresTool() {
res.ToolChoice = openai.ChatCompletionToolChoiceOptionUnionParam{
OfAuto: openai.String("required"),
}
}
if request.Temperature != nil {
res.Temperature = *request.Temperature
}
// is this an o1-* model?
isO1 := strings.Split(o.model, "-")[0] == "o1"
if isO1 {
// o1 models do not support system messages, so if any messages are system messages, we need to convert them to
// user messages
for i, msg := range res.Messages {
if msg.Role == "system" {
res.Messages[i].Role = "user"
}
// these are known models that do not support custom temperatures
// all the o* models
// gpt-5* models
if !strings.HasPrefix(o.model, "o") && !strings.HasPrefix(o.model, "gpt-5") {
res.Temperature = openai.Float(*request.Temperature)
}
}
return res
}
func (o openaiImpl) responseToLLMResponse(response oai.ChatCompletionResponse) Response {
res := Response{}
func (o openaiImpl) responseToLLMResponse(response *openai.ChatCompletion) Response {
var res Response
if response == nil {
return res
}
if len(response.Choices) == 0 {
return res
}
for _, choice := range response.Choices {
var toolCalls []ToolCall
for _, call := range choice.Message.ToolCalls {
fmt.Println("responseToLLMResponse: call:", call.Function.Arguments)
toolCall := ToolCall{
ID: call.ID,
FunctionCall: FunctionCall{
Name: call.Function.Name,
Arguments: call.Function.Arguments,
Arguments: strings.TrimSpace(call.Function.Arguments),
},
}
fmt.Println("toolCall.FunctionCall.Arguments:", toolCall.FunctionCall.Arguments)
toolCalls = append(toolCalls, toolCall)
}
res.Choices = append(res.Choices, ResponseChoice{
Content: choice.Message.Content,
Role: Role(choice.Message.Role),
Name: choice.Message.Name,
Refusal: choice.Message.Refusal,
Calls: toolCalls,
})
@@ -128,16 +99,22 @@ func (o openaiImpl) responseToLLMResponse(response oai.ChatCompletionResponse) R
}
func (o openaiImpl) ChatComplete(ctx context.Context, request Request) (Response, error) {
cl := oai.NewClient(o.key)
var opts = []option.RequestOption{
option.WithAPIKey(o.key),
}
req := o.requestToOpenAIRequest(request)
if o.baseUrl != "" {
opts = append(opts, option.WithBaseURL(o.baseUrl))
}
resp, err := cl.CreateChatCompletion(ctx, req)
cl := openai.NewClient(opts...)
fmt.Println("resp:", fmt.Sprintf("%#v", resp))
req := o.newRequestToOpenAIRequest(request)
resp, err := cl.Chat.Completions.New(ctx, req)
if err != nil {
return Response{}, fmt.Errorf("unhandled openaiImpl error: %w", err)
return Response{}, fmt.Errorf("unhandled openai error: %w", err)
}
return o.responseToLLMResponse(resp), nil
@@ -145,7 +122,201 @@ func (o openaiImpl) ChatComplete(ctx context.Context, request Request) (Response
func (o openaiImpl) ModelVersion(modelVersion string) (ChatCompletion, error) {
return openaiImpl{
key: o.key,
model: modelVersion,
key: o.key,
model: modelVersion,
baseUrl: o.baseUrl,
}, nil
}
// inputToChatCompletionMessages converts an Input to OpenAI chat completion messages.
func inputToChatCompletionMessages(input Input, model string) []openai.ChatCompletionMessageParamUnion {
switch v := input.(type) {
case Message:
return messageToChatCompletionMessages(v, model)
case ToolCall:
return toolCallToChatCompletionMessages(v)
case ToolCallResponse:
return toolCallResponseToChatCompletionMessages(v)
case ResponseChoice:
return responseChoiceToChatCompletionMessages(v)
default:
return nil
}
}
func messageToChatCompletionMessages(m Message, model string) []openai.ChatCompletionMessageParamUnion {
var res openai.ChatCompletionMessageParamUnion
var arrayOfContentParts []openai.ChatCompletionContentPartUnionParam
var textContent param.Opt[string]
for _, img := range m.Images {
if img.Base64 != "" {
arrayOfContentParts = append(arrayOfContentParts,
openai.ChatCompletionContentPartUnionParam{
OfImageURL: &openai.ChatCompletionContentPartImageParam{
ImageURL: openai.ChatCompletionContentPartImageImageURLParam{
URL: "data:" + img.ContentType + ";base64," + img.Base64,
},
},
},
)
} else if img.Url != "" {
arrayOfContentParts = append(arrayOfContentParts,
openai.ChatCompletionContentPartUnionParam{
OfImageURL: &openai.ChatCompletionContentPartImageParam{
ImageURL: openai.ChatCompletionContentPartImageImageURLParam{
URL: img.Url,
},
},
},
)
}
}
if m.Text != "" {
if len(arrayOfContentParts) > 0 {
arrayOfContentParts = append(arrayOfContentParts,
openai.ChatCompletionContentPartUnionParam{
OfText: &openai.ChatCompletionContentPartTextParam{
Text: "\n",
},
},
)
} else {
textContent = openai.String(m.Text)
}
}
a := strings.Split(model, "-")
useSystemInsteadOfDeveloper := true
if len(a) > 1 && a[0][0] == 'o' {
useSystemInsteadOfDeveloper = false
}
switch m.Role {
case RoleSystem:
if useSystemInsteadOfDeveloper {
res = openai.ChatCompletionMessageParamUnion{
OfSystem: &openai.ChatCompletionSystemMessageParam{
Content: openai.ChatCompletionSystemMessageParamContentUnion{
OfString: textContent,
},
},
}
} else {
res = openai.ChatCompletionMessageParamUnion{
OfDeveloper: &openai.ChatCompletionDeveloperMessageParam{
Content: openai.ChatCompletionDeveloperMessageParamContentUnion{
OfString: textContent,
},
},
}
}
case RoleUser:
var name param.Opt[string]
if m.Name != "" {
name = openai.String(m.Name)
}
res = openai.ChatCompletionMessageParamUnion{
OfUser: &openai.ChatCompletionUserMessageParam{
Name: name,
Content: openai.ChatCompletionUserMessageParamContentUnion{
OfString: textContent,
OfArrayOfContentParts: arrayOfContentParts,
},
},
}
case RoleAssistant:
var name param.Opt[string]
if m.Name != "" {
name = openai.String(m.Name)
}
res = openai.ChatCompletionMessageParamUnion{
OfAssistant: &openai.ChatCompletionAssistantMessageParam{
Name: name,
Content: openai.ChatCompletionAssistantMessageParamContentUnion{
OfString: textContent,
},
},
}
}
return []openai.ChatCompletionMessageParamUnion{res}
}
func toolCallToChatCompletionMessages(t ToolCall) []openai.ChatCompletionMessageParamUnion {
return []openai.ChatCompletionMessageParamUnion{{
OfAssistant: &openai.ChatCompletionAssistantMessageParam{
ToolCalls: []openai.ChatCompletionMessageToolCallParam{
{
ID: t.ID,
Function: openai.ChatCompletionMessageToolCallFunctionParam{
Name: t.FunctionCall.Name,
Arguments: t.FunctionCall.Arguments,
},
},
},
},
}}
}
func toolCallResponseToChatCompletionMessages(t ToolCallResponse) []openai.ChatCompletionMessageParamUnion {
var refusal string
if t.Error != nil {
refusal = t.Error.Error()
}
result := t.Result
if refusal != "" {
if result != "" {
result = fmt.Sprint(result) + " (error in execution: " + refusal + ")"
} else {
result = "error in execution:" + refusal
}
}
return []openai.ChatCompletionMessageParamUnion{{
OfTool: &openai.ChatCompletionToolMessageParam{
ToolCallID: t.ID,
Content: openai.ChatCompletionToolMessageParamContentUnion{
OfString: openai.String(fmt.Sprint(result)),
},
},
}}
}
func responseChoiceToChatCompletionMessages(r ResponseChoice) []openai.ChatCompletionMessageParamUnion {
var as openai.ChatCompletionAssistantMessageParam
if r.Name != "" {
as.Name = openai.String(r.Name)
}
if r.Refusal != "" {
as.Refusal = openai.String(r.Refusal)
}
if r.Content != "" {
as.Content.OfString = openai.String(r.Content)
}
for _, call := range r.Calls {
as.ToolCalls = append(as.ToolCalls, openai.ChatCompletionMessageToolCallParam{
ID: call.ID,
Function: openai.ChatCompletionMessageToolCallFunctionParam{
Name: call.FunctionCall.Name,
Arguments: call.FunctionCall.Arguments,
},
})
}
return []openai.ChatCompletionMessageParamUnion{
{
OfAssistant: &as,
},
}
}

50
parse.go Normal file
View File

@@ -0,0 +1,50 @@
package llm
import (
"strings"
)
// Providers are the allowed shortcuts in the providers, e.g.: if you set { "openai": OpenAI("key") } that'll allow
// for the "openai" provider to be used when parsed.
type Providers map[string]LLM
// Parse will parse the provided input and attempt to return a LLM chat completion interface.
// Input should be in the provided format:
// - provider/modelname
//
// where provider is a key inside Providers, and the modelname being passed to the LLM interface's GetModel
func (providers Providers) Parse(input string) ChatCompletion {
sections := strings.Split(input, "/")
var provider LLM
var ok bool
var modelVersion string
if len(sections) < 2 {
// is there a default provider?
provider, ok = providers["default"]
if !ok {
panic("expected format: \"provider/model\" or provide a \"default\" provider to the Parse callback")
}
modelVersion = sections[0]
} else {
provider, ok = providers[sections[0]]
modelVersion = sections[1]
}
if !ok {
panic("expected format: \"provider/model\" or provide a \"default\" provider to the Parse callback")
}
if provider == nil {
panic("unknown provider: " + sections[0])
}
res, err := provider.ModelVersion(modelVersion)
if err != nil {
panic(err)
}
return res
}

View File

@@ -0,0 +1,11 @@
// Package anthropic provides the Anthropic LLM provider.
package anthropic
import (
llm "gitea.stevedudenhoeffer.com/steve/go-llm"
)
// New creates a new Anthropic LLM provider with the given API key.
func New(key string) llm.LLM {
return llm.Anthropic(key)
}

11
provider/google/google.go Normal file
View File

@@ -0,0 +1,11 @@
// Package google provides the Google LLM provider.
package google
import (
llm "gitea.stevedudenhoeffer.com/steve/go-llm"
)
// New creates a new Google LLM provider with the given API key.
func New(key string) llm.LLM {
return llm.Google(key)
}

11
provider/openai/openai.go Normal file
View File

@@ -0,0 +1,11 @@
// Package openai provides the OpenAI LLM provider.
package openai
import (
llm "gitea.stevedudenhoeffer.com/steve/go-llm"
)
// New creates a new OpenAI LLM provider with the given API key.
func New(key string) llm.LLM {
return llm.OpenAI(key)
}

51
request.go Normal file
View File

@@ -0,0 +1,51 @@
package llm
// Input is the interface for conversation inputs.
// Types that implement this interface can be part of a conversation:
// Message, ToolCall, ToolCallResponse, and ResponseChoice.
type Input interface {
// isInput is a marker method to ensure only valid types implement this interface.
isInput()
}
// Implement Input interface for all valid input types.
func (Message) isInput() {}
func (ToolCall) isInput() {}
func (ToolCallResponse) isInput() {}
func (ResponseChoice) isInput() {}
// Request represents a request to a language model.
type Request struct {
Conversation []Input
Messages []Message
Toolbox ToolBox
Temperature *float64
}
// NextRequest will take the current request's conversation, messages, the response, and any tool results, and
// return a new request with the conversation updated to include the response and tool results.
func (req Request) NextRequest(resp ResponseChoice, toolResults []ToolCallResponse) Request {
var res Request
res.Toolbox = req.Toolbox
res.Temperature = req.Temperature
res.Conversation = make([]Input, len(req.Conversation))
copy(res.Conversation, req.Conversation)
// now for every input message, convert those to an Input to add to the conversation
for _, msg := range req.Messages {
res.Conversation = append(res.Conversation, msg)
}
if resp.Content != "" || resp.Refusal != "" || len(resp.Calls) > 0 {
res.Conversation = append(res.Conversation, resp)
}
// if there are tool results, then we need to add those to the conversation
for _, result := range toolResults {
res.Conversation = append(res.Conversation, result)
}
return res
}

52
response.go Normal file
View File

@@ -0,0 +1,52 @@
package llm
// ResponseChoice represents a single choice in a response.
type ResponseChoice struct {
Index int
Role Role
Content string
Refusal string
Name string
Calls []ToolCall
}
func (r ResponseChoice) toRaw() map[string]any {
res := map[string]any{
"index": r.Index,
"role": r.Role,
"content": r.Content,
"refusal": r.Refusal,
"name": r.Name,
}
calls := make([]map[string]any, 0, len(r.Calls))
for _, call := range r.Calls {
calls = append(calls, call.toRaw())
}
res["tool_calls"] = calls
return res
}
func (r ResponseChoice) toInput() []Input {
var res []Input
for _, call := range r.Calls {
res = append(res, call)
}
if r.Content != "" || r.Refusal != "" {
res = append(res, Message{
Role: RoleAssistant,
Text: r.Content,
})
}
return res
}
// Response represents a response from a language model.
type Response struct {
Choices []ResponseChoice
}

View File

@@ -3,8 +3,6 @@ package schema
import (
"reflect"
"strings"
"github.com/sashabaranov/go-openai/jsonschema"
)
// GetType will, given an interface{} that is a struct (NOT a pointer to a struct), return the Type of the struct that
@@ -27,23 +25,28 @@ func getFromType(t reflect.Type, b basic) Type {
switch t.Kind() {
case reflect.String:
b.DataType = jsonschema.String
b.DataType = TypeString
b.typeName = "string"
return b
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
b.DataType = jsonschema.Integer
b.DataType = TypeInteger
b.typeName = "integer"
return b
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
b.DataType = jsonschema.Integer
b.DataType = TypeInteger
b.typeName = "integer"
return b
case reflect.Float32, reflect.Float64:
b.DataType = jsonschema.Number
b.DataType = TypeNumber
b.typeName = "number"
return b
case reflect.Bool:
b.DataType = jsonschema.Boolean
b.DataType = TypeBoolean
b.typeName = "boolean"
return b
case reflect.Struct:
@@ -89,6 +92,8 @@ func getField(f reflect.StructField, index int) Type {
}
}
b.DataType = TypeString
b.typeName = "string"
return enum{
basic: b,
values: vals,
@@ -99,15 +104,26 @@ func getField(f reflect.StructField, index int) Type {
return getFromType(t, b)
}
func getObject(t reflect.Type) object {
func getObject(t reflect.Type) Object {
fields := make(map[string]Type, t.NumField())
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
fields[field.Name] = getField(field, i)
if field.Anonymous {
// if the field is anonymous, we need to get the fields of the anonymous struct
// and add them to the object
anon := getObject(field.Type)
for k, v := range anon.fields {
fields[k] = v
}
continue
} else {
fields[field.Name] = getField(field, i)
}
}
return object{
basic: basic{DataType: jsonschema.Object},
return Object{
basic: basic{DataType: TypeObject, typeName: "object"},
fields: fields,
}
}
@@ -115,7 +131,8 @@ func getObject(t reflect.Type) object {
func getArray(t reflect.Type) array {
res := array{
basic: basic{
DataType: jsonschema.Array,
DataType: TypeArray,
typeName: "array",
},
}

View File

@@ -4,7 +4,8 @@ import (
"errors"
"reflect"
"github.com/sashabaranov/go-openai/jsonschema"
"github.com/openai/openai-go"
"google.golang.org/genai"
)
type array struct {
@@ -14,17 +15,28 @@ type array struct {
items Type
}
func (a array) SchemaType() jsonschema.DataType {
return jsonschema.Array
func (a array) OpenAIParameters() openai.FunctionParameters {
return openai.FunctionParameters{
"type": "array",
"description": a.Description(),
"items": a.items.OpenAIParameters(),
}
}
func (a array) Definition() jsonschema.Definition {
def := a.basic.Definition()
def.Type = jsonschema.Array
i := a.items.Definition()
def.Items = &i
def.AdditionalProperties = false
return def
func (a array) GoogleParameters() *genai.Schema {
return &genai.Schema{
Type: genai.TypeArray,
Description: a.Description(),
Items: a.items.GoogleParameters(),
}
}
func (a array) AnthropicInputSchema() map[string]any {
return map[string]any{
"type": "array",
"description": a.Description(),
"items": a.items.AnthropicInputSchema(),
}
}
func (a array) FromAny(val any) (reflect.Value, error) {

View File

@@ -5,14 +5,27 @@ import (
"reflect"
"strconv"
"github.com/sashabaranov/go-openai/jsonschema"
"github.com/openai/openai-go"
"google.golang.org/genai"
)
// just enforcing that basic implements Type
var _ Type = basic{}
type DataType string
const (
TypeString DataType = "string"
TypeInteger DataType = "integer"
TypeNumber DataType = "number"
TypeBoolean DataType = "boolean"
TypeObject DataType = "object"
TypeArray DataType = "array"
)
type basic struct {
jsonschema.DataType
DataType
typeName string
// index is the position of the parameter in the StructField of the function's parameter struct
index int
@@ -25,17 +38,64 @@ type basic struct {
description string
}
func (b basic) SchemaType() jsonschema.DataType {
return b.DataType
func (b basic) OpenAIParameters() openai.FunctionParameters {
return openai.FunctionParameters{
"type": b.typeName,
"description": b.description,
}
}
func (b basic) Definition() jsonschema.Definition {
return jsonschema.Definition{
Type: b.DataType,
func (b basic) GoogleParameters() *genai.Schema {
var t = genai.TypeUnspecified
switch b.DataType {
case TypeString:
t = genai.TypeString
case TypeInteger:
t = genai.TypeInteger
case TypeNumber:
t = genai.TypeNumber
case TypeBoolean:
t = genai.TypeBoolean
case TypeObject:
t = genai.TypeObject
case TypeArray:
t = genai.TypeArray
default:
t = genai.TypeUnspecified
}
return &genai.Schema{
Type: t,
Description: b.description,
}
}
func (b basic) AnthropicInputSchema() map[string]any {
var t = "string"
switch b.DataType {
case TypeString:
t = "string"
case TypeInteger:
t = "integer"
case TypeNumber:
t = "number"
case TypeBoolean:
t = "boolean"
case TypeObject:
t = "object"
case TypeArray:
t = "array"
default:
t = "unknown"
}
return map[string]any{
"type": t,
"description": b.description,
}
}
func (b basic) Required() bool {
return b.required
}
@@ -48,12 +108,12 @@ func (b basic) FromAny(val any) (reflect.Value, error) {
v := reflect.ValueOf(val)
switch b.DataType {
case jsonschema.String:
case TypeString:
var val = v.String()
return reflect.ValueOf(val), nil
case jsonschema.Integer:
case TypeInteger:
if v.Kind() == reflect.Float64 {
return v.Convert(reflect.TypeOf(int(0))), nil
} else if v.Kind() != reflect.Int {
@@ -62,7 +122,7 @@ func (b basic) FromAny(val any) (reflect.Value, error) {
return v, nil
}
case jsonschema.Number:
case TypeNumber:
if v.Kind() == reflect.Float64 {
return v.Convert(reflect.TypeOf(float64(0))), nil
} else if v.Kind() != reflect.Float64 {
@@ -71,7 +131,7 @@ func (b basic) FromAny(val any) (reflect.Value, error) {
return v, nil
}
case jsonschema.Boolean:
case TypeBoolean:
if v.Kind() == reflect.Bool {
return v, nil
} else if v.Kind() == reflect.String {

View File

@@ -3,10 +3,10 @@ package schema
import (
"errors"
"reflect"
"slices"
"golang.org/x/exp/slices"
"github.com/sashabaranov/go-openai/jsonschema"
"github.com/openai/openai-go"
"google.golang.org/genai"
)
type enum struct {
@@ -15,14 +15,28 @@ type enum struct {
values []string
}
func (e enum) SchemaType() jsonschema.DataType {
return jsonschema.String
func (e enum) FunctionParameters() openai.FunctionParameters {
return openai.FunctionParameters{
"type": "string",
"description": e.Description(),
"enum": e.values,
}
}
func (e enum) Definition() jsonschema.Definition {
def := e.basic.Definition()
def.Enum = e.values
return def
func (e enum) GoogleParameters() *genai.Schema {
return &genai.Schema{
Type: genai.TypeString,
Description: e.Description(),
Enum: e.values,
}
}
func (e enum) AnthropicInputSchema() map[string]any {
return map[string]any{
"type": "string",
"description": e.Description(),
"enum": e.values,
}
}
func (e enum) FromAny(val any) (reflect.Value, error) {

View File

@@ -4,34 +4,125 @@ import (
"errors"
"reflect"
"github.com/sashabaranov/go-openai/jsonschema"
"github.com/openai/openai-go"
"google.golang.org/genai"
)
type object struct {
const (
// SyntheticFieldPrefix is any prefix that is added to any synthetic fields that are added to the object, to prevent
// collisions with the fields in the struct.
SyntheticFieldPrefix = "__"
)
type Object struct {
basic
ref reflect.Type
fields map[string]Type
// syntheticFields are fields that are not in the struct but are generated by a system.
synetheticFields map[string]Type
}
func (o object) SchemaType() jsonschema.DataType {
return jsonschema.Object
}
func (o object) Definition() jsonschema.Definition {
def := o.basic.Definition()
def.Type = jsonschema.Object
def.Properties = make(map[string]jsonschema.Definition)
for k, v := range o.fields {
def.Properties[k] = v.Definition()
func (o Object) WithSyntheticField(name string, description string) Object {
if o.synetheticFields == nil {
o.synetheticFields = map[string]Type{}
}
def.AdditionalProperties = false
return def
o.synetheticFields[name] = basic{
DataType: TypeString,
typeName: "string",
index: -1,
required: false,
description: description,
}
return o
}
func (o object) FromAny(val any) (reflect.Value, error) {
func (o Object) SyntheticFields() map[string]Type {
return o.synetheticFields
}
func (o Object) OpenAIParameters() openai.FunctionParameters {
var properties = map[string]openai.FunctionParameters{}
var required []string
for k, v := range o.fields {
properties[k] = v.OpenAIParameters()
if v.Required() {
required = append(required, k)
}
}
for k, v := range o.synetheticFields {
properties[SyntheticFieldPrefix+k] = v.OpenAIParameters()
if v.Required() {
required = append(required, SyntheticFieldPrefix+k)
}
}
var res = openai.FunctionParameters{
"type": "object",
"description": o.Description(),
"properties": properties,
}
if len(required) > 0 {
res["required"] = required
}
return res
}
func (o Object) GoogleParameters() *genai.Schema {
var properties = map[string]*genai.Schema{}
var required []string
for k, v := range o.fields {
properties[k] = v.GoogleParameters()
if v.Required() {
required = append(required, k)
}
}
var res = &genai.Schema{
Type: genai.TypeObject,
Description: o.Description(),
Properties: properties,
}
if len(required) > 0 {
res.Required = required
}
return res
}
func (o Object) AnthropicInputSchema() map[string]any {
var properties = map[string]any{}
var required []string
for k, v := range o.fields {
properties[k] = v.AnthropicInputSchema()
if v.Required() {
required = append(required, k)
}
}
var res = map[string]any{
"type": "object",
"description": o.Description(),
"properties": properties,
}
if len(required) > 0 {
res["required"] = required
}
return res
}
// FromAny converts the value from any to the correct type, returning the value, and an error if any
func (o Object) FromAny(val any) (reflect.Value, error) {
// if the value is nil, we can't do anything
if val == nil {
return reflect.Value{}, nil
@@ -68,7 +159,7 @@ func (o object) FromAny(val any) (reflect.Value, error) {
return obj, nil
}
func (o object) SetValueOnField(obj reflect.Value, val reflect.Value) {
func (o Object) SetValueOnField(obj reflect.Value, val reflect.Value) {
// if this basic type is not required that means it's a pointer type so we need to set the value to the address of the value
if !o.required {
val = val.Addr()

134
schema/raw.go Normal file
View File

@@ -0,0 +1,134 @@
package schema
import (
"encoding/json"
"fmt"
"reflect"
"github.com/openai/openai-go"
"google.golang.org/genai"
)
// Raw represents a raw JSON schema that is passed through directly.
// This is used for MCP tools where we receive the schema from the server.
type Raw struct {
schema map[string]any
}
// NewRaw creates a new Raw schema from a map.
func NewRaw(schema map[string]any) Raw {
if schema == nil {
schema = map[string]any{
"type": "object",
"properties": map[string]any{},
}
}
return Raw{schema: schema}
}
// NewRawFromJSON creates a new Raw schema from JSON bytes.
func NewRawFromJSON(data []byte) (Raw, error) {
var schema map[string]any
if err := json.Unmarshal(data, &schema); err != nil {
return Raw{}, fmt.Errorf("failed to parse JSON schema: %w", err)
}
return NewRaw(schema), nil
}
func (r Raw) OpenAIParameters() openai.FunctionParameters {
return openai.FunctionParameters(r.schema)
}
func (r Raw) GoogleParameters() *genai.Schema {
return mapToGenaiSchema(r.schema)
}
func (r Raw) AnthropicInputSchema() map[string]any {
return r.schema
}
func (r Raw) Required() bool {
return false
}
func (r Raw) Description() string {
if desc, ok := r.schema["description"].(string); ok {
return desc
}
return ""
}
func (r Raw) FromAny(val any) (reflect.Value, error) {
return reflect.ValueOf(val), nil
}
func (r Raw) SetValueOnField(obj reflect.Value, val reflect.Value) {
// No-op for raw schemas
}
// mapToGenaiSchema converts a map[string]any JSON schema to genai.Schema
func mapToGenaiSchema(m map[string]any) *genai.Schema {
if m == nil {
return nil
}
schema := &genai.Schema{}
// Type
if t, ok := m["type"].(string); ok {
switch t {
case "string":
schema.Type = genai.TypeString
case "number":
schema.Type = genai.TypeNumber
case "integer":
schema.Type = genai.TypeInteger
case "boolean":
schema.Type = genai.TypeBoolean
case "array":
schema.Type = genai.TypeArray
case "object":
schema.Type = genai.TypeObject
}
}
// Description
if desc, ok := m["description"].(string); ok {
schema.Description = desc
}
// Enum
if enum, ok := m["enum"].([]any); ok {
for _, e := range enum {
if s, ok := e.(string); ok {
schema.Enum = append(schema.Enum, s)
}
}
}
// Properties (for objects)
if props, ok := m["properties"].(map[string]any); ok {
schema.Properties = make(map[string]*genai.Schema)
for k, v := range props {
if vm, ok := v.(map[string]any); ok {
schema.Properties[k] = mapToGenaiSchema(vm)
}
}
}
// Required
if req, ok := m["required"].([]any); ok {
for _, r := range req {
if s, ok := r.(string); ok {
schema.Required = append(schema.Required, s)
}
}
}
// Items (for arrays)
if items, ok := m["items"].(map[string]any); ok {
schema.Items = mapToGenaiSchema(items)
}
return schema
}

View File

@@ -3,12 +3,17 @@ package schema
import (
"reflect"
"github.com/sashabaranov/go-openai/jsonschema"
"github.com/openai/openai-go"
"google.golang.org/genai"
)
type Type interface {
SchemaType() jsonschema.DataType
Definition() jsonschema.Definition
OpenAIParameters() openai.FunctionParameters
GoogleParameters() *genai.Schema
AnthropicInputSchema() map[string]any
//SchemaType() jsonschema.DataType
//Definition() jsonschema.Definition
Required() bool
Description() string

View File

@@ -1,59 +1,87 @@
package go_llm
package llm
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/sashabaranov/go-openai"
)
// ToolBox is a collection of tools that OpenAI can use to execute functions.
// It is a wrapper around a collection of functions, and provides a way to automatically call the correct function with
// the correct parameters.
type ToolBox struct {
funcs []Function
names map[string]Function
functions map[string]Function
mcpServers map[string]*MCPServer // tool name -> MCP server that provides it
dontRequireTool bool
}
func NewToolBox(fns ...*Function) *ToolBox {
func NewToolBox(fns ...Function) ToolBox {
res := ToolBox{
funcs: []Function{},
names: map[string]Function{},
functions: map[string]Function{},
}
for _, f := range fns {
o := *f
res.names[o.Name] = o
res.funcs = append(res.funcs, o)
}
return &res
}
func (t *ToolBox) WithFunction(f Function) *ToolBox {
t2 := *t
t2.names[f.Name] = f
t2.funcs = append(t2.funcs, f)
return &t2
}
// ToOpenAI will convert the current ToolBox to a slice of openai.Tool, which can be used to send to the OpenAI API.
func (t *ToolBox) toOpenAI() []openai.Tool {
var res []openai.Tool
for _, f := range t.funcs {
res = append(res, openai.Tool{
Type: "function",
Function: f.toOpenAIFunction(),
})
res.functions[f.Name] = f
}
return res
}
func (t *ToolBox) ToToolChoice() any {
if len(t.funcs) == 0 {
func (t ToolBox) Functions() []Function {
var res []Function
for _, f := range t.functions {
res = append(res, f)
}
return res
}
func (t ToolBox) WithFunction(f Function) ToolBox {
t.functions[f.Name] = f
return t
}
func (t ToolBox) WithFunctions(fns ...Function) ToolBox {
for _, f := range fns {
t.functions[f.Name] = f
}
return t
}
func (t ToolBox) WithSyntheticFieldsAddedToAllFunctions(fieldsAndDescriptions map[string]string) ToolBox {
for k, v := range t.functions {
t.functions[k] = v.WithSyntheticFields(fieldsAndDescriptions)
}
return t
}
func (t ToolBox) ForEachFunction(fn func(f Function)) {
for _, f := range t.functions {
fn(f)
}
}
func (t ToolBox) WithFunctionRemoved(name string) ToolBox {
delete(t.functions, name)
return t
}
func (t ToolBox) WithRequireTool(val bool) ToolBox {
t.dontRequireTool = !val
return t
}
func (t ToolBox) RequiresTool() bool {
return !t.dontRequireTool && len(t.functions) > 0
}
func (t ToolBox) ToToolChoice() any {
if len(t.functions) == 0 {
return nil
}
@@ -64,8 +92,20 @@ var (
ErrFunctionNotFound = errors.New("function not found")
)
func (t *ToolBox) ExecuteFunction(ctx context.Context, functionName string, params string) (string, error) {
f, ok := t.names[functionName]
func (t ToolBox) executeFunction(ctx *Context, functionName string, params string) (any, error) {
// Check if this is an MCP tool
if server, ok := t.mcpServers[functionName]; ok {
var args map[string]any
if params != "" {
if err := json.Unmarshal([]byte(params), &args); err != nil {
return nil, fmt.Errorf("failed to parse MCP tool arguments: %w", err)
}
}
return server.CallTool(ctx, functionName, args)
}
// Regular function
f, ok := t.functions[functionName]
if !ok {
return "", newError(ErrFunctionNotFound, fmt.Errorf("function \"%s\" not found", functionName))
@@ -74,6 +114,61 @@ func (t *ToolBox) ExecuteFunction(ctx context.Context, functionName string, para
return f.Execute(ctx, params)
}
func (t *ToolBox) Execute(ctx context.Context, toolCall ToolCall) (string, error) {
return t.ExecuteFunction(ctx, toolCall.FunctionCall.Name, toolCall.FunctionCall.Arguments)
func (t ToolBox) Execute(ctx *Context, toolCall ToolCall) (any, error) {
return t.executeFunction(ctx.WithToolCall(&toolCall), toolCall.FunctionCall.Name, toolCall.FunctionCall.Arguments)
}
func (t ToolBox) GetSyntheticParametersFromFunctionContext(ctx context.Context) map[string]string {
val := ctx.Value("syntheticParameters")
if val == nil {
return nil
}
syntheticParameters, ok := val.(map[string]string)
if !ok {
return nil
}
return syntheticParameters
}
// ExecuteCallbacks will execute all the tool calls in the given list, and call the given callbacks when a new function is created, and when a function is finished.
// OnNewFunction is called when a new function is created
// OnFunctionFinished is called when a function is finished
func (t ToolBox) ExecuteCallbacks(ctx *Context, toolCalls []ToolCall, OnNewFunction func(ctx context.Context, funcName string, parameter string) (any, error), OnFunctionFinished func(ctx context.Context, funcName string, parameter string, result any, err error, newFunctionResult any) error) ([]ToolCallResponse, error) {
var res []ToolCallResponse
for _, call := range toolCalls {
ctx := ctx.WithToolCall(&call)
if call.FunctionCall.Name == "" {
return nil, newError(ErrFunctionNotFound, errors.New("function name is empty"))
}
var arg any
if OnNewFunction != nil {
var err error
arg, err = OnNewFunction(ctx, call.FunctionCall.Name, call.FunctionCall.Arguments)
if err != nil {
return nil, newError(ErrFunctionNotFound, err)
}
}
out, err := t.Execute(ctx, call)
if OnFunctionFinished != nil {
err := OnFunctionFinished(ctx, call.FunctionCall.Name, call.FunctionCall.Arguments, out, err, arg)
if err != nil {
return nil, newError(ErrFunctionNotFound, err)
}
}
res = append(res, ToolCallResponse{
ID: call.ID,
Result: out,
Error: err,
})
}
return res, nil
}