Drop the github.com/sashabaranov/go-openai dependency in favor of an internal HTTP client. The LLM package is now split into: - llm.go: type definitions (Message, ChatRequest, ChatResponse, etc.) - client.go: client construction and configuration - client_completions.go: Completions and CompletionsStream methods - client_models.go: ListModels - client_tools.go: tool call execution CompletionsStream uses typed SSE events (delta, done, message) so the frontend can render progressive streaming output. Move tool.go and tool_test.go from internal/tool/ into internal/llm/ to co-locate the registry with the client that consumes it. Update job.go and service.go to use the new llm.Message types and Completions/CompletionsStream methods. Remove the Voice field from chat request/response (voice is now tracked via message metadata). Frontend: handle delta/done/message SSE events, render a streaming placeholder that receives progressive text deltas, and finalize with full message binding (reasoning, tool calls, actions).
225 lines
5.5 KiB
Go
225 lines
5.5 KiB
Go
package llm
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os/exec"
|
|
"text/template"
|
|
"time"
|
|
|
|
"code.chimeric.al/chimerical/odidere/internal/config"
|
|
)
|
|
|
|
// fn provides template functions available to argument templates.
|
|
var fn = template.FuncMap{
|
|
"json": func(v any) string {
|
|
b, _ := json.Marshal(v)
|
|
return string(b)
|
|
},
|
|
}
|
|
|
|
// Tool represents an external tool that can be invoked by LLMs.
|
|
// Tools are executed as subprocesses with templated arguments,
|
|
// and described to the LLM via their JSON Schema parameters.
|
|
type Tool struct {
|
|
// Name uniquely identifies the tool within a registry.
|
|
Name string
|
|
// Description explains the tool's purpose for the LLM.
|
|
Description string
|
|
// Command is the executable path or name.
|
|
Command string
|
|
// Arguments are Go templates expanded with LLM-provided parameters.
|
|
Arguments []string
|
|
// Parameters is a JSON Schema describing expected input from the LLM.
|
|
Parameters map[string]any
|
|
// timeout is the parsed execution duration limit.
|
|
timeout time.Duration
|
|
}
|
|
|
|
// NewTool creates a Tool from a ToolConfig.
|
|
func NewTool(cfg config.ToolConfig) (*Tool, error) {
|
|
var timeout time.Duration
|
|
if cfg.Timeout != "" {
|
|
var err error
|
|
timeout, err = time.ParseDuration(cfg.Timeout)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"invalid timeout %q: %w", cfg.Timeout, err,
|
|
)
|
|
}
|
|
}
|
|
|
|
return &Tool{
|
|
Name: cfg.Name,
|
|
Description: cfg.Description,
|
|
Command: cfg.Command,
|
|
Arguments: cfg.Arguments,
|
|
Parameters: cfg.Parameters,
|
|
timeout: timeout,
|
|
}, nil
|
|
}
|
|
|
|
// ParseArguments expands argument templates with the provided JSON data.
|
|
// The args parameter should be a JSON object string; empty string or "{}"
|
|
// results in an empty data map. Templates producing empty strings are
|
|
// filtered from the result, allowing conditional arguments.
|
|
func (t *Tool) ParseArguments(args string) ([]string, error) {
|
|
var data = map[string]any{}
|
|
if args != "" && args != "{}" {
|
|
if err := json.Unmarshal([]byte(args), &data); err != nil {
|
|
return nil, fmt.Errorf(
|
|
"invalid arguments JSON: %w", err,
|
|
)
|
|
}
|
|
}
|
|
|
|
var result []string
|
|
for _, v := range t.Arguments {
|
|
tmpl, err := template.New("").Funcs(fn).Parse(v)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"invalid template %q: %w", v, err,
|
|
)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := tmpl.Execute(&buf, data); err != nil {
|
|
return nil, fmt.Errorf(
|
|
"execute template %q: %w", v, err,
|
|
)
|
|
}
|
|
|
|
// Filter out empty strings (unused conditional arguments).
|
|
if s := buf.String(); s != "" {
|
|
result = append(result, s)
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// ToAPI converts the tool definition into the API format suitable for
|
|
// inclusion in a ChatRequest.
|
|
func (t *Tool) ToAPI() APITool {
|
|
return APITool{
|
|
Type: "function",
|
|
Function: &FunctionDef{
|
|
Name: t.Name,
|
|
Description: t.Description,
|
|
Parameters: t.Parameters,
|
|
},
|
|
}
|
|
}
|
|
|
|
// Registry holds tools indexed by name and handles their execution.
|
|
// It validates all tool definitions at construction time to fail fast on
|
|
// configuration errors.
|
|
type Registry struct {
|
|
tools map[string]*Tool
|
|
}
|
|
|
|
// NewRegistry creates a registry from the provided tool definitions.
|
|
// Returns an error if any tool fails validation or if duplicate names exist.
|
|
func NewRegistry(tools []config.ToolConfig) (*Registry, error) {
|
|
var r = &Registry{
|
|
tools: make(map[string]*Tool),
|
|
}
|
|
for _, tc := range tools {
|
|
t, err := NewTool(tc)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"invalid tool %q: %w", tc.Name, err,
|
|
)
|
|
}
|
|
|
|
if _, exists := r.tools[t.Name]; exists {
|
|
return nil, fmt.Errorf(
|
|
"duplicate tool name: %s", t.Name,
|
|
)
|
|
}
|
|
r.tools[t.Name] = t
|
|
}
|
|
|
|
return r, nil
|
|
}
|
|
|
|
// Get returns a tool by name and a boolean indicating if it was found.
|
|
func (r *Registry) Get(name string) (*Tool, bool) {
|
|
tool, ok := r.tools[name]
|
|
return tool, ok
|
|
}
|
|
|
|
// List returns all registered tool names in arbitrary order.
|
|
func (r *Registry) List() []string {
|
|
names := make([]string, 0, len(r.tools))
|
|
for name := range r.tools {
|
|
names = append(names, name)
|
|
}
|
|
return names
|
|
}
|
|
|
|
// ToAPI converts all registered tools into API format suitable for
|
|
// inclusion in a ChatRequest.
|
|
func (r *Registry) ToAPI() []APITool {
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
tools := make([]APITool, 0, len(r.tools))
|
|
for _, t := range r.tools {
|
|
tools = append(tools, t.ToAPI())
|
|
}
|
|
return tools
|
|
}
|
|
|
|
// Execute runs a tool by name with the provided JSON arguments.
|
|
// It expands argument templates, executes the command as a subprocess, and
|
|
// returns stdout on success. The context can be used for cancellation;
|
|
// tool-specific timeouts are applied on top of any context deadline.
|
|
func (r *Registry) Execute(
|
|
ctx context.Context, name string, args string,
|
|
) (string, error) {
|
|
tool, ok := r.tools[name]
|
|
if !ok {
|
|
return "", fmt.Errorf("unknown tool: %s", name)
|
|
}
|
|
|
|
// Evaluate argument templates.
|
|
cmdArgs, err := tool.ParseArguments(args)
|
|
if err != nil {
|
|
return "", fmt.Errorf("parse arguments: %w", err)
|
|
}
|
|
|
|
// If defined, use the timeout.
|
|
if tool.timeout > 0 {
|
|
var cancel context.CancelFunc
|
|
ctx, cancel = context.WithTimeout(ctx, tool.timeout)
|
|
defer cancel()
|
|
}
|
|
|
|
// Setup and run the command.
|
|
var (
|
|
stdout, stderr bytes.Buffer
|
|
cmd = exec.CommandContext(
|
|
ctx, tool.Command, cmdArgs...,
|
|
)
|
|
)
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
if ctx.Err() == context.DeadlineExceeded && tool.timeout > 0 {
|
|
return "", fmt.Errorf(
|
|
"tool %s timed out after %v",
|
|
name, tool.timeout,
|
|
)
|
|
}
|
|
return "", fmt.Errorf(
|
|
"tool %s: %w\nstderr: %s",
|
|
name, err, stderr.String(),
|
|
)
|
|
}
|
|
|
|
return stdout.String(), nil
|
|
}
|