first commit

This commit is contained in:
2026-03-08 15:40:34 +07:00
commit 8dc496b626
159 changed files with 27932 additions and 0 deletions
+58
View File
@@ -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
}
+163
View File
@@ -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)
}
+92
View File
@@ -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))
}
}
+222
View File
@@ -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
}
+121
View File
@@ -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)
}
})
}
}