Files
go-llm/client.go
T
2026-09-28 14:03:53 +02:00

279 lines
6.9 KiB
Go

// Package gollm is a chat LLM wire client: Chat, Stream, and List Models
// through an Ollama-native or OpenAI-compatible Adapter.
package gollm
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
// Adapter is which native protocol the Client speaks.
type Adapter string
const (
AdapterOpenAI Adapter = "openai"
AdapterOllama Adapter = "ollama"
)
// Role is a Message role.
type Role string
const (
RoleSystem Role = "system"
RoleUser Role = "user"
RoleAssistant Role = "assistant"
)
// Message is one chat turn.
type Message struct {
Role Role
Content string
}
// Options are Chat/Stream parameters. Temperature and MaxTokens are omitted when nil.
type Options struct {
Model string
Temperature *float64
MaxTokens *int
}
// Client is a configured caller bound to one Adapter, base URL, and optional API key.
type Client struct {
adapter Adapter
baseURL string
apiKey string
http *http.Client
}
// NewClient validates Adapter and base URL. apiKey may be empty.
func NewClient(adapter Adapter, baseURL, apiKey string) (*Client, error) {
switch adapter {
case AdapterOpenAI, AdapterOllama:
default:
return nil, fmt.Errorf("unknown Adapter %q", adapter)
}
base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
if base == "" {
return nil, fmt.Errorf("base URL is required")
}
return &Client{adapter: adapter, baseURL: base, apiKey: apiKey, http: http.DefaultClient}, nil
}
// Chat returns the assistant text for messages.
func (c *Client) Chat(ctx context.Context, messages []Message, opt Options) (string, error) {
switch c.adapter {
case AdapterOpenAI:
return c.openaiChat(ctx, messages, opt)
case AdapterOllama:
return c.ollamaChat(ctx, messages, opt)
default:
return "", fmt.Errorf("unknown Adapter %q", c.adapter)
}
}
// Stream yields assistant text chunks for the same Chat request.
func (c *Client) Stream(ctx context.Context, messages []Message, opt Options, yield func(chunk string) error) error {
switch c.adapter {
case AdapterOpenAI:
return c.openaiStream(ctx, messages, opt, yield)
case AdapterOllama:
return c.ollamaStream(ctx, messages, opt, yield)
default:
return fmt.Errorf("unknown Adapter %q", c.adapter)
}
}
// ListModels returns server-side Model names.
func (c *Client) ListModels(ctx context.Context) ([]string, error) {
switch c.adapter {
case AdapterOpenAI:
return c.openaiListModels(ctx)
case AdapterOllama:
return c.ollamaListModels(ctx)
default:
return nil, fmt.Errorf("unknown Adapter %q", c.adapter)
}
}
func (c *Client) auth(req *http.Request) {
if c.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
}
type openaiChatRequest struct {
Model string `json:"model"`
Messages []openaiMessage `json:"messages"`
Stream bool `json:"stream,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
}
type openaiMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
func openaiMessages(messages []Message) []openaiMessage {
out := make([]openaiMessage, len(messages))
for i, m := range messages {
out[i] = openaiMessage{Role: string(m.Role), Content: m.Content}
}
return out
}
func (c *Client) openaiChat(ctx context.Context, messages []Message, opt Options) (string, error) {
body, err := json.Marshal(openaiChatRequest{
Model: opt.Model,
Messages: openaiMessages(messages),
Temperature: opt.Temperature,
MaxTokens: opt.MaxTokens,
})
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/chat/completions", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
c.auth(req)
res, err := c.http.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
raw, err := io.ReadAll(res.Body)
if err != nil {
return "", err
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
return "", fmt.Errorf("openai chat: %s: %s", res.Status, truncate(raw))
}
var parsed struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return "", err
}
if len(parsed.Choices) == 0 {
return "", fmt.Errorf("openai chat: no choices")
}
return parsed.Choices[0].Message.Content, nil
}
func (c *Client) openaiStream(ctx context.Context, messages []Message, opt Options, yield func(string) error) error {
body, err := json.Marshal(openaiChatRequest{
Model: opt.Model,
Messages: openaiMessages(messages),
Stream: true,
Temperature: opt.Temperature,
MaxTokens: opt.MaxTokens,
})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/chat/completions", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
c.auth(req)
res, err := c.http.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode >= 300 {
raw, _ := io.ReadAll(res.Body)
return fmt.Errorf("openai stream: %s: %s", res.Status, truncate(raw))
}
sc := bufio.NewScanner(res.Body)
sc.Buffer(nil, 1024*1024)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || !strings.HasPrefix(line, "data:") {
continue
}
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if payload == "[DONE]" {
return nil
}
var parsed struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
}
if err := json.Unmarshal([]byte(payload), &parsed); err != nil {
return err
}
if len(parsed.Choices) == 0 {
continue
}
chunk := parsed.Choices[0].Delta.Content
if chunk == "" {
continue
}
if err := yield(chunk); err != nil {
return err
}
}
return sc.Err()
}
func (c *Client) openaiListModels(ctx context.Context) ([]string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/v1/models", nil)
if err != nil {
return nil, err
}
c.auth(req)
res, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
raw, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
return nil, fmt.Errorf("openai models: %s: %s", res.Status, truncate(raw))
}
var parsed struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return nil, err
}
names := make([]string, 0, len(parsed.Data))
for _, d := range parsed.Data {
if d.ID != "" {
names = append(names, d.ID)
}
}
return names, nil
}
func truncate(b []byte) string {
s := strings.TrimSpace(string(b))
if len(s) > 200 {
return s[:200]
}
return s
}