first commit
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
package llm
|
||||
|
||||
import "context"
|
||||
|
||||
// Client is the interface for LLM providers.
|
||||
type Client interface {
|
||||
// ChatStream sends messages to the LLM and streams the response.
|
||||
// The callback is called for each chunk. Return a non-nil error to abort.
|
||||
ChatStream(ctx context.Context, opts ChatOptions, fn func(StreamChunk) error) error
|
||||
|
||||
// Ping checks if the LLM is reachable and the model is available.
|
||||
Ping() error
|
||||
|
||||
// Model returns the current model name.
|
||||
Model() string
|
||||
|
||||
// Embed generates embeddings for the given texts using the specified model.
|
||||
Embed(ctx context.Context, model string, texts []string) ([][]float32, error)
|
||||
}
|
||||
|
||||
// ChatOptions holds parameters for a chat request.
|
||||
type ChatOptions struct {
|
||||
Messages []Message
|
||||
Tools []ToolDef
|
||||
System string
|
||||
}
|
||||
|
||||
// Message represents a conversation message.
|
||||
type Message struct {
|
||||
Role string `json:"role"` // system, user, assistant, tool
|
||||
Content string `json:"content"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
ToolName string `json:"tool_name,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
}
|
||||
|
||||
// StreamChunk is a piece of a streaming response.
|
||||
type StreamChunk struct {
|
||||
Text string // incremental text content
|
||||
ToolCalls []ToolCall // tool calls (usually in final chunk)
|
||||
Done bool // true on the last chunk
|
||||
EvalCount int // tokens generated (only on Done)
|
||||
PromptEvalCount int // prompt tokens evaluated (only on Done)
|
||||
}
|
||||
|
||||
// ToolCall represents a tool invocation requested by the LLM.
|
||||
type ToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
}
|
||||
|
||||
// ToolDef defines a tool the LLM can call.
|
||||
type ToolDef struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters map[string]any `json:"parameters"` // JSON Schema
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type ModelManager struct {
|
||||
baseURL string
|
||||
numCtx int
|
||||
clients map[string]*OllamaClient
|
||||
currentModel string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
var _ Client = (*ModelManager)(nil)
|
||||
|
||||
func NewModelManager(baseURL string, numCtx int) *ModelManager {
|
||||
return &ModelManager{
|
||||
baseURL: baseURL,
|
||||
numCtx: numCtx,
|
||||
clients: make(map[string]*OllamaClient),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ModelManager) GetClient(modelName string) (*OllamaClient, error) {
|
||||
m.mu.RLock()
|
||||
client, exists := m.clients[modelName]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if client, exists := m.clients[modelName]; exists {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
client, err := NewOllamaClient(m.baseURL, modelName, m.numCtx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create client for %s: %w", modelName, err)
|
||||
}
|
||||
|
||||
m.clients[modelName] = client
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (m *ModelManager) SetCurrentModel(model string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
client, err := NewOllamaClient(m.baseURL, model, m.numCtx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create client for %s: %w", model, err)
|
||||
}
|
||||
|
||||
m.clients[model] = client
|
||||
m.currentModel = model
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ModelManager) CurrentModel() string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.currentModel
|
||||
}
|
||||
|
||||
func (m *ModelManager) ChatStream(ctx context.Context, opts ChatOptions, fn func(StreamChunk) error) error {
|
||||
m.mu.RLock()
|
||||
model := m.currentModel
|
||||
m.mu.RUnlock()
|
||||
|
||||
if model == "" {
|
||||
return fmt.Errorf("no model selected")
|
||||
}
|
||||
|
||||
client, err := m.GetClient(model)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return client.ChatStream(ctx, opts, fn)
|
||||
}
|
||||
|
||||
func (m *ModelManager) ChatStreamForModel(ctx context.Context, model string, opts ChatOptions, fn func(StreamChunk) error) error {
|
||||
client, err := m.GetClient(model)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return client.ChatStream(ctx, opts, fn)
|
||||
}
|
||||
|
||||
func (m *ModelManager) Ping() error {
|
||||
m.mu.RLock()
|
||||
model := m.currentModel
|
||||
m.mu.RUnlock()
|
||||
|
||||
if model == "" {
|
||||
return fmt.Errorf("no model selected")
|
||||
}
|
||||
|
||||
client, err := m.GetClient(model)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return client.Ping()
|
||||
}
|
||||
|
||||
func (m *ModelManager) PingModel(model string) error {
|
||||
client, err := m.GetClient(model)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return client.Ping()
|
||||
}
|
||||
|
||||
func (m *ModelManager) Embed(ctx context.Context, model string, texts []string) ([][]float32, error) {
|
||||
client, err := m.GetClient(model)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.Embed(ctx, model, texts)
|
||||
}
|
||||
|
||||
func (m *ModelManager) EmbedWithCurrentModel(ctx context.Context, texts []string) ([][]float32, error) {
|
||||
m.mu.RLock()
|
||||
model := m.currentModel
|
||||
m.mu.RUnlock()
|
||||
|
||||
if model == "" {
|
||||
return nil, fmt.Errorf("no model selected")
|
||||
}
|
||||
return m.Embed(ctx, model, texts)
|
||||
}
|
||||
|
||||
func (m *ModelManager) Close() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for range m.clients {
|
||||
}
|
||||
m.clients = make(map[string]*OllamaClient)
|
||||
}
|
||||
|
||||
func (m *ModelManager) BaseURL() string {
|
||||
return m.baseURL
|
||||
}
|
||||
|
||||
func (m *ModelManager) NumCtx() int {
|
||||
return m.numCtx
|
||||
}
|
||||
|
||||
func (m *ModelManager) Model() string {
|
||||
return m.CurrentModel()
|
||||
}
|
||||
|
||||
// ListModels returns model names available in Ollama at the manager's base URL.
|
||||
func (m *ModelManager) ListModels(ctx context.Context) ([]string, error) {
|
||||
return ListModels(ctx, m.baseURL)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewModelManager(t *testing.T) {
|
||||
m := NewModelManager("http://localhost:11434", 4096)
|
||||
|
||||
if m.baseURL != "http://localhost:11434" {
|
||||
t.Errorf("baseURL = %q, want %q", m.baseURL, "http://localhost:11434")
|
||||
}
|
||||
if m.numCtx != 4096 {
|
||||
t.Errorf("numCtx = %d, want %d", m.numCtx, 4096)
|
||||
}
|
||||
if m.clients == nil {
|
||||
t.Error("clients map should be initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelManagerBaseURL(t *testing.T) {
|
||||
m := NewModelManager("http://custom:9999", 2048)
|
||||
if m.BaseURL() != "http://custom:9999" {
|
||||
t.Errorf("BaseURL() = %q, want %q", m.BaseURL(), "http://custom:9999")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelManagerNumCtx(t *testing.T) {
|
||||
m := NewModelManager("http://localhost:11434", 8192)
|
||||
if m.NumCtx() != 8192 {
|
||||
t.Errorf("NumCtx() = %d, want %d", m.NumCtx(), 8192)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelManagerCurrentModel(t *testing.T) {
|
||||
m := NewModelManager("http://localhost:11434", 4096)
|
||||
|
||||
// Should return empty when no model set
|
||||
if m.CurrentModel() != "" {
|
||||
t.Errorf("CurrentModel() = %q, want %q", m.CurrentModel(), "")
|
||||
}
|
||||
|
||||
// Set a model
|
||||
m.SetCurrentModel("llama3")
|
||||
|
||||
if m.CurrentModel() != "llama3" {
|
||||
t.Errorf("CurrentModel() = %q, want %q", m.CurrentModel(), "llama3")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelManagerChatStreamNoModel(t *testing.T) {
|
||||
m := NewModelManager("http://localhost:11434", 4096)
|
||||
|
||||
err := m.ChatStream(nil, ChatOptions{}, func(chunk StreamChunk) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
t.Error("ChatStream should fail when no model is set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelManagerPingNoModel(t *testing.T) {
|
||||
m := NewModelManager("http://localhost:11434", 4096)
|
||||
|
||||
err := m.Ping()
|
||||
|
||||
if err == nil {
|
||||
t.Error("Ping should fail when no model is set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelManagerEmbedWithCurrentModelNoModel(t *testing.T) {
|
||||
m := NewModelManager("http://localhost:11434", 4096)
|
||||
|
||||
_, err := m.EmbedWithCurrentModel(nil, []string{"test"})
|
||||
|
||||
if err == nil {
|
||||
t.Error("EmbedWithCurrentModel should fail when no model is set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelManagerClose(t *testing.T) {
|
||||
m := NewModelManager("http://localhost:11434", 4096)
|
||||
|
||||
// Should not panic
|
||||
m.Close()
|
||||
|
||||
if len(m.clients) != 0 {
|
||||
t.Errorf("after Close, clients map should be empty, got %d", len(m.clients))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
ollamaapi "github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
// OllamaClient implements Client using the official Ollama Go library.
|
||||
type OllamaClient struct {
|
||||
client *ollamaapi.Client
|
||||
model string
|
||||
numCtx int
|
||||
}
|
||||
|
||||
// NewOllamaClient creates a new Ollama client.
|
||||
func NewOllamaClient(baseURL, model string, numCtx int) (*OllamaClient, error) {
|
||||
// The official client reads OLLAMA_HOST, but we want to support our config too.
|
||||
if baseURL != "" {
|
||||
os.Setenv("OLLAMA_HOST", baseURL)
|
||||
}
|
||||
|
||||
client, err := ollamaapi.ClientFromEnvironment()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create ollama client: %w", err)
|
||||
}
|
||||
|
||||
return &OllamaClient{
|
||||
client: client,
|
||||
model: model,
|
||||
numCtx: numCtx,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (o *OllamaClient) Model() string { return o.model }
|
||||
|
||||
// Ping checks Ollama is running and the model exists.
|
||||
func (o *OllamaClient) Ping() error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Check the model is available by requesting a show.
|
||||
req := &ollamaapi.ShowRequest{Model: o.model}
|
||||
_, err := o.client.Show(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model %q not available: %w", o.model, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChatStream sends a chat request and streams the response via callback.
|
||||
func (o *OllamaClient) ChatStream(ctx context.Context, opts ChatOptions, fn func(StreamChunk) error) error {
|
||||
|
||||
messages := make([]ollamaapi.Message, 0, len(opts.Messages)+1)
|
||||
if opts.System != "" {
|
||||
messages = append(messages, ollamaapi.Message{
|
||||
Role: "system",
|
||||
Content: opts.System,
|
||||
})
|
||||
}
|
||||
for _, m := range opts.Messages {
|
||||
msg := ollamaapi.Message{
|
||||
Role: m.Role,
|
||||
Content: m.Content,
|
||||
ToolName: m.ToolName,
|
||||
ToolCallID: m.ToolCallID,
|
||||
}
|
||||
// Convert tool calls for assistant messages.
|
||||
for _, tc := range m.ToolCalls {
|
||||
args := ollamaapi.NewToolCallFunctionArguments()
|
||||
for k, v := range tc.Arguments {
|
||||
args.Set(k, v)
|
||||
}
|
||||
msg.ToolCalls = append(msg.ToolCalls, ollamaapi.ToolCall{
|
||||
ID: tc.ID,
|
||||
Function: ollamaapi.ToolCallFunction{
|
||||
Name: tc.Name,
|
||||
Arguments: args,
|
||||
},
|
||||
})
|
||||
}
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
|
||||
tools := convertTools(opts.Tools)
|
||||
req := &ollamaapi.ChatRequest{
|
||||
Model: o.model,
|
||||
Messages: messages,
|
||||
Tools: tools,
|
||||
Options: map[string]any{
|
||||
"num_ctx": o.numCtx,
|
||||
},
|
||||
}
|
||||
|
||||
return o.client.Chat(ctx, req, func(resp ollamaapi.ChatResponse) error {
|
||||
chunk := StreamChunk{
|
||||
Text: resp.Message.Content,
|
||||
Done: resp.Done,
|
||||
}
|
||||
if resp.Done {
|
||||
chunk.EvalCount = resp.EvalCount
|
||||
chunk.PromptEvalCount = resp.PromptEvalCount
|
||||
}
|
||||
// Collect tool calls from the response.
|
||||
for _, tc := range resp.Message.ToolCalls {
|
||||
chunk.ToolCalls = append(chunk.ToolCalls, ToolCall{
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Arguments: tc.Function.Arguments.ToMap(),
|
||||
})
|
||||
}
|
||||
return fn(chunk)
|
||||
})
|
||||
}
|
||||
|
||||
// Embed generates embeddings for the given texts using the specified model.
|
||||
func (o *OllamaClient) Embed(ctx context.Context, model string, texts []string) ([][]float32, error) {
|
||||
resp, err := o.client.Embed(ctx, &ollamaapi.EmbedRequest{
|
||||
Model: model,
|
||||
Input: texts,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("embedding failed: %w", err)
|
||||
}
|
||||
return resp.Embeddings, nil
|
||||
}
|
||||
|
||||
// convertTools transforms our ToolDef slice into Ollama's Tools format.
|
||||
func convertTools(defs []ToolDef) ollamaapi.Tools {
|
||||
if len(defs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
tools := make(ollamaapi.Tools, 0, len(defs))
|
||||
for _, d := range defs {
|
||||
props := ollamaapi.NewToolPropertiesMap()
|
||||
var required []string
|
||||
|
||||
// Extract properties from JSON Schema.
|
||||
if propsRaw, ok := d.Parameters["properties"].(map[string]any); ok {
|
||||
for name, schema := range propsRaw {
|
||||
schemaMap, _ := schema.(map[string]any)
|
||||
prop := ollamaapi.ToolProperty{
|
||||
Description: strFromMap(schemaMap, "description"),
|
||||
}
|
||||
if t, ok := schemaMap["type"].(string); ok {
|
||||
prop.Type = ollamaapi.PropertyType{t}
|
||||
}
|
||||
if enumRaw, ok := schemaMap["enum"].([]any); ok {
|
||||
prop.Enum = enumRaw
|
||||
}
|
||||
props.Set(name, prop)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract required fields.
|
||||
if reqRaw, ok := d.Parameters["required"].([]any); ok {
|
||||
for _, r := range reqRaw {
|
||||
if s, ok := r.(string); ok {
|
||||
required = append(required, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tools = append(tools, ollamaapi.Tool{
|
||||
Type: "function",
|
||||
Function: ollamaapi.ToolFunction{
|
||||
Name: d.Name,
|
||||
Description: d.Description,
|
||||
Parameters: ollamaapi.ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Properties: props,
|
||||
Required: required,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
func strFromMap(m map[string]any, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
s, _ := m[key].(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// BaseURL returns the configured Ollama base URL for display.
|
||||
func (o *OllamaClient) BaseURL() string {
|
||||
if v := os.Getenv("OLLAMA_HOST"); v != "" {
|
||||
return v
|
||||
}
|
||||
return "http://localhost:11434"
|
||||
}
|
||||
|
||||
// ParseBaseURL validates the Ollama URL.
|
||||
func ParseBaseURL(rawURL string) (*url.URL, error) {
|
||||
return url.Parse(rawURL)
|
||||
}
|
||||
|
||||
// ListModels returns model names available in Ollama at baseURL.
|
||||
func ListModels(ctx context.Context, baseURL string) ([]string, error) {
|
||||
if baseURL != "" {
|
||||
os.Setenv("OLLAMA_HOST", baseURL)
|
||||
}
|
||||
client, err := ollamaapi.ClientFromEnvironment()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ollama client: %w", err)
|
||||
}
|
||||
resp, err := client.List(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ollama list: %w", err)
|
||||
}
|
||||
names := make([]string, 0, len(resp.Models))
|
||||
for _, m := range resp.Models {
|
||||
names = append(names, m.Name)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConvertTools(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []ToolDef
|
||||
wantNil bool
|
||||
wantCount int
|
||||
}{
|
||||
{
|
||||
name: "nil input",
|
||||
input: nil,
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "single tool with properties and required",
|
||||
input: []ToolDef{
|
||||
{
|
||||
Name: "read_file",
|
||||
Description: "Read a file",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "file path",
|
||||
},
|
||||
},
|
||||
"required": []any{"path"},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantCount: 1,
|
||||
},
|
||||
{
|
||||
name: "tool without properties in parameters",
|
||||
input: []ToolDef{
|
||||
{
|
||||
Name: "noop",
|
||||
Description: "Does nothing",
|
||||
Parameters: map[string]any{"type": "object"},
|
||||
},
|
||||
},
|
||||
wantCount: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := convertTools(tt.input)
|
||||
if tt.wantNil {
|
||||
if result != nil {
|
||||
t.Errorf("convertTools() = %v, want nil", result)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(result) != tt.wantCount {
|
||||
t.Errorf("convertTools() returned %d tools, want %d", len(result), tt.wantCount)
|
||||
}
|
||||
if tt.wantCount > 0 {
|
||||
tool := result[0]
|
||||
if tool.Function.Name != tt.input[0].Name {
|
||||
t.Errorf("tool name = %q, want %q", tool.Function.Name, tt.input[0].Name)
|
||||
}
|
||||
if tool.Function.Description != tt.input[0].Description {
|
||||
t.Errorf("tool description = %q, want %q", tool.Function.Description, tt.input[0].Description)
|
||||
}
|
||||
if tool.Type != "function" {
|
||||
t.Errorf("tool type = %q, want %q", tool.Type, "function")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrFromMap(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
m map[string]any
|
||||
key string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "key present",
|
||||
m: map[string]any{"description": "a desc"},
|
||||
key: "description",
|
||||
want: "a desc",
|
||||
},
|
||||
{
|
||||
name: "key missing",
|
||||
m: map[string]any{"other": "value"},
|
||||
key: "description",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "nil map",
|
||||
m: nil,
|
||||
key: "description",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "non-string value",
|
||||
m: map[string]any{"count": 42},
|
||||
key: "count",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := strFromMap(tt.m, tt.key)
|
||||
if got != tt.want {
|
||||
t.Errorf("strFromMap() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user