Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f90719583e | ||
|
|
7b63c8db58 | ||
|
|
be4f54890b |
@@ -1,10 +1,41 @@
|
||||
# go-llm
|
||||
|
||||
Go Library for chat LLM calls (`gitea.neitzel.de/konrad/go-llm`). Domain language is in `CONTEXT.md`. Decisions live in the Workspace at `docs/adr/go-llm/`.
|
||||
Go library for chat LLM calls. One client speaks either the Ollama native protocol or an OpenAI-compatible protocol. Callers pass messages. The library does not load files or build product prompts.
|
||||
|
||||
v1: Chat, Stream, List Models, temperature, and max tokens. Adapters: Ollama native (`/api/chat`, `/api/tags`) and OpenAI-compatible (`/v1/chat/completions`, `/v1/models`). No embeddings, tools, raw generate, Nextcloud types, or story types.
|
||||
Import: `gitea.neitzel.de/konrad/go-llm` (package `gollm`).
|
||||
|
||||
```go
|
||||
client, err := gollm.NewClient(gollm.AdapterOpenAI, "http://localhost:8000", "")
|
||||
text, err := client.Chat(ctx, []gollm.Message{{Role: gollm.RoleUser, Content: "Hello"}}, gollm.Options{Model: "llama"})
|
||||
```bash
|
||||
go get gitea.neitzel.de/konrad/go-llm
|
||||
```
|
||||
|
||||
- [Guide](docs/guide.md) — how to call each capability
|
||||
- [API](docs/api.md) — every exported symbol
|
||||
- [Domain language](CONTEXT.md)
|
||||
|
||||
## Included
|
||||
|
||||
- [Adapters](docs/guide.md#adapters) — Ollama native and OpenAI-compatible
|
||||
- [Chat](docs/guide.md#chat) — one assistant reply, including temperature and max tokens
|
||||
- [Stream](docs/guide.md#stream) — the same call, delivered as text chunks
|
||||
- [List Models](docs/guide.md#list-models) — names the server reports
|
||||
|
||||
## Excluded
|
||||
|
||||
- Embeddings, tools, and raw generate
|
||||
- Nextcloud types and story types
|
||||
|
||||
## Testing
|
||||
|
||||
Unit tests use `httptest`. No live model server is required.
|
||||
|
||||
`go test ./...` fails when [`docs/api.md`](docs/api.md) does not match the exported API. Regenerate it with:
|
||||
|
||||
```bash
|
||||
UPDATE_API_DOCS=1 go test -run TestAPIDoc -count=1
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- **Tales** — ExApp that consumes this library
|
||||
- Workspace ADR `docs/adr/go-llm/0001-native-adapters.md`
|
||||
- Workspace ADR `docs/adr/go-llm/0002-wire-only.md`
|
||||
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
package gollm_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/doc"
|
||||
"go/doc/comment"
|
||||
"go/parser"
|
||||
"go/printer"
|
||||
"go/token"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const apiModulePath = "gitea.neitzel.de/konrad/go-llm"
|
||||
|
||||
func TestAPIDoc(t *testing.T) {
|
||||
got, err := renderAPIDoc(".", apiModulePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const path = "docs/api.md"
|
||||
if os.Getenv("UPDATE_API_DOCS") == "1" {
|
||||
if err := os.MkdirAll("docs", 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
want, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
if string(want) != got {
|
||||
t.Fatalf("docs/api.md is stale; regenerate with UPDATE_API_DOCS=1 go test -run TestAPIDoc -count=1\n%s", firstDiff(string(want), got))
|
||||
}
|
||||
}
|
||||
|
||||
func renderAPIDoc(dir, modulePath string) (string, error) {
|
||||
fset := token.NewFileSet()
|
||||
pkgs, err := parser.ParseDir(fset, dir, func(fi os.FileInfo) bool {
|
||||
return !strings.HasSuffix(fi.Name(), "_test.go")
|
||||
}, parser.ParseComments)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(pkgs) != 1 {
|
||||
return "", fmt.Errorf("expected 1 package in %s, found %d", dir, len(pkgs))
|
||||
}
|
||||
var astPkg *ast.Package
|
||||
for _, p := range pkgs {
|
||||
astPkg = p
|
||||
}
|
||||
pkg := doc.New(astPkg, modulePath, 0)
|
||||
lookup := symLookup(pkg)
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("# API\n\n")
|
||||
b.WriteString("Generated from the package comment and every exported declaration. Do not edit.\n")
|
||||
b.WriteString("Regenerate with `UPDATE_API_DOCS=1 go test -run TestAPIDoc -count=1`.\n\n")
|
||||
fmt.Fprintf(&b, "## Package %s\n\n", pkg.Name)
|
||||
writeDoc(&b, pkg.Doc, lookup)
|
||||
writeValues(&b, fset, "Constants", pkg.Consts, "### ", lookup)
|
||||
writeValues(&b, fset, "Variables", pkg.Vars, "### ", lookup)
|
||||
writeFuncs(&b, fset, "Functions", pkg.Funcs, "### ", lookup)
|
||||
if len(pkg.Types) > 0 {
|
||||
b.WriteString("## Types\n\n")
|
||||
for _, typ := range pkg.Types {
|
||||
writeType(&b, fset, typ, lookup)
|
||||
}
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func writeType(b *strings.Builder, fset *token.FileSet, typ *doc.Type, lookup func(string, string) bool) {
|
||||
fmt.Fprintf(b, "### %s\n\n", typ.Name)
|
||||
sig, err := formatGenDecl(fset, typ.Decl)
|
||||
if err != nil {
|
||||
fmt.Fprintf(b, "_(signature unavailable: %v)_\n\n", err)
|
||||
} else {
|
||||
writeCode(b, sig)
|
||||
}
|
||||
writeDoc(b, typ.Doc, lookup)
|
||||
writeValues(b, fset, "", typ.Consts, "#### ", lookup)
|
||||
writeValues(b, fset, "", typ.Vars, "#### ", lookup)
|
||||
writeFuncs(b, fset, "", typ.Funcs, "#### ", lookup)
|
||||
if !isInterface(typ) {
|
||||
writeFuncs(b, fset, "", typ.Methods, "#### ", lookup)
|
||||
}
|
||||
}
|
||||
|
||||
func writeValues(b *strings.Builder, fset *token.FileSet, title string, vals []*doc.Value, heading string, lookup func(string, string) bool) {
|
||||
if len(vals) == 0 {
|
||||
return
|
||||
}
|
||||
if title != "" {
|
||||
fmt.Fprintf(b, "## %s\n\n", title)
|
||||
}
|
||||
for _, v := range vals {
|
||||
for _, name := range v.Names {
|
||||
fmt.Fprintf(b, "%s%s\n\n", heading, name)
|
||||
}
|
||||
sig, err := formatGenDecl(fset, v.Decl)
|
||||
if err != nil {
|
||||
fmt.Fprintf(b, "_(signature unavailable: %v)_\n\n", err)
|
||||
} else {
|
||||
writeCode(b, sig)
|
||||
}
|
||||
writeDoc(b, v.Doc, lookup)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFuncs(b *strings.Builder, fset *token.FileSet, title string, fns []*doc.Func, heading string, lookup func(string, string) bool) {
|
||||
if len(fns) == 0 {
|
||||
return
|
||||
}
|
||||
if title != "" {
|
||||
fmt.Fprintf(b, "## %s\n\n", title)
|
||||
}
|
||||
for _, fn := range fns {
|
||||
name := fn.Name
|
||||
if fn.Recv != "" {
|
||||
name = strings.TrimPrefix(fn.Recv, "*") + "." + fn.Name
|
||||
}
|
||||
fmt.Fprintf(b, "%s%s\n\n", heading, name)
|
||||
sig, err := formatFunc(fset, fn.Decl)
|
||||
if err != nil {
|
||||
fmt.Fprintf(b, "_(signature unavailable: %v)_\n\n", err)
|
||||
} else {
|
||||
writeCode(b, sig)
|
||||
}
|
||||
writeDoc(b, fn.Doc, lookup)
|
||||
}
|
||||
}
|
||||
|
||||
func writeCode(b *strings.Builder, sig string) {
|
||||
b.WriteString("```go\n")
|
||||
b.WriteString(sig)
|
||||
b.WriteString("\n```\n\n")
|
||||
}
|
||||
|
||||
func writeDoc(b *strings.Builder, raw string, lookup func(string, string) bool) {
|
||||
text := renderComment(raw, lookup)
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
b.WriteString(text)
|
||||
if !strings.HasSuffix(text, "\n") {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
|
||||
func symLookup(pkg *doc.Package) func(string, string) bool {
|
||||
syms := map[string]bool{}
|
||||
methods := map[string]bool{}
|
||||
addNames := func(vals []*doc.Value) {
|
||||
for _, v := range vals {
|
||||
for _, name := range v.Names {
|
||||
syms[name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
addFuncs := func(fns []*doc.Func) {
|
||||
for _, fn := range fns {
|
||||
syms[fn.Name] = true
|
||||
if fn.Recv != "" {
|
||||
recv := strings.TrimPrefix(fn.Recv, "*")
|
||||
methods[recv+"."+fn.Name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
addNames(pkg.Consts)
|
||||
addNames(pkg.Vars)
|
||||
addFuncs(pkg.Funcs)
|
||||
for _, typ := range pkg.Types {
|
||||
syms[typ.Name] = true
|
||||
addNames(typ.Consts)
|
||||
addNames(typ.Vars)
|
||||
addFuncs(typ.Funcs)
|
||||
addFuncs(typ.Methods)
|
||||
}
|
||||
return func(recv, name string) bool {
|
||||
if recv == "" {
|
||||
return syms[name]
|
||||
}
|
||||
return methods[recv+"."+name]
|
||||
}
|
||||
}
|
||||
|
||||
func renderComment(raw string, lookup func(string, string) bool) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
var parser comment.Parser
|
||||
parser.LookupSym = lookup
|
||||
var printer comment.Printer
|
||||
printer.HeadingLevel = 4
|
||||
printer.DocLinkURL = func(link *comment.DocLink) string {
|
||||
if link.ImportPath != "" {
|
||||
return link.DefaultURL("https://pkg.go.dev")
|
||||
}
|
||||
name := link.Name
|
||||
if link.Recv != "" {
|
||||
name = link.Recv + "." + link.Name
|
||||
}
|
||||
return "#" + githubAnchor(name)
|
||||
}
|
||||
return strings.TrimSpace(string(printer.Markdown(parser.Parse(raw))))
|
||||
}
|
||||
|
||||
// githubAnchor matches GitHub heading slugs: lowercase, drop punctuation, keep
|
||||
// letters, digits, hyphens, and underscores. "Client.Chat" becomes "clientchat".
|
||||
func githubAnchor(name string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(name) {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func formatGenDecl(fset *token.FileSet, decl *ast.GenDecl) (string, error) {
|
||||
if decl == nil {
|
||||
return "", fmt.Errorf("nil decl")
|
||||
}
|
||||
copyDecl := *decl
|
||||
copyDecl.Doc = nil
|
||||
return formatNode(fset, ©Decl)
|
||||
}
|
||||
|
||||
func formatFunc(fset *token.FileSet, decl *ast.FuncDecl) (string, error) {
|
||||
if decl == nil {
|
||||
return "", fmt.Errorf("nil decl")
|
||||
}
|
||||
copyDecl := *decl
|
||||
copyDecl.Doc = nil
|
||||
copyDecl.Body = nil
|
||||
return formatNode(fset, ©Decl)
|
||||
}
|
||||
|
||||
func formatNode(fset *token.FileSet, node ast.Node) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := printer.Fprint(&buf, fset, node); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(buf.String()), nil
|
||||
}
|
||||
|
||||
func isInterface(typ *doc.Type) bool {
|
||||
if typ.Decl == nil || len(typ.Decl.Specs) != 1 {
|
||||
return false
|
||||
}
|
||||
spec, ok := typ.Decl.Specs[0].(*ast.TypeSpec)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
_, ok = spec.Type.(*ast.InterfaceType)
|
||||
return ok
|
||||
}
|
||||
|
||||
func firstDiff(want, got string) string {
|
||||
w := strings.Split(want, "\n")
|
||||
g := strings.Split(got, "\n")
|
||||
n := min(len(g), len(w))
|
||||
for i := range n {
|
||||
if w[i] != g[i] {
|
||||
return fmt.Sprintf("line %d:\n got: %s\nwant: %s", i+1, g[i], w[i])
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("length got %d lines, want %d lines", len(g), len(w))
|
||||
}
|
||||
@@ -13,37 +13,50 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Adapter is which native protocol the Client speaks.
|
||||
// Adapter selects the wire protocol a Client speaks.
|
||||
// [AdapterOpenAI] uses POST /v1/chat/completions and GET /v1/models.
|
||||
// [AdapterOllama] uses POST /api/chat and GET /api/tags.
|
||||
type Adapter string
|
||||
|
||||
const (
|
||||
// AdapterOpenAI selects the OpenAI-compatible protocol.
|
||||
AdapterOpenAI Adapter = "openai"
|
||||
// AdapterOllama selects the Ollama native protocol.
|
||||
AdapterOllama Adapter = "ollama"
|
||||
)
|
||||
|
||||
// Role is a Message role.
|
||||
// Role is the speaker of one Message.
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleSystem Role = "system"
|
||||
RoleUser Role = "user"
|
||||
// RoleSystem is a system instruction.
|
||||
RoleSystem Role = "system"
|
||||
// RoleUser is the caller's turn.
|
||||
RoleUser Role = "user"
|
||||
// RoleAssistant is a previous model turn.
|
||||
RoleAssistant Role = "assistant"
|
||||
)
|
||||
|
||||
// Message is one chat turn.
|
||||
// Message is one chat turn. Role and Content are sent unchanged.
|
||||
type Message struct {
|
||||
Role Role
|
||||
Content string
|
||||
}
|
||||
|
||||
// Options are Chat/Stream parameters. Temperature and MaxTokens are omitted when nil.
|
||||
// Options configures one Chat or Stream call.
|
||||
// Model is the server-side model name. The Client does not check that it is
|
||||
// set; the server rejects an empty model.
|
||||
// A nil Temperature or MaxTokens is left out of the request.
|
||||
// On [AdapterOllama], MaxTokens is sent as num_predict.
|
||||
type Options struct {
|
||||
Model string
|
||||
Temperature *float64
|
||||
MaxTokens *int
|
||||
}
|
||||
|
||||
// Client is a configured caller bound to one Adapter, base URL, and optional API key.
|
||||
// Client calls one Adapter at one base URL.
|
||||
// An empty API key sends no Authorization header.
|
||||
// A non-empty key is sent as a Bearer token on every request.
|
||||
type Client struct {
|
||||
adapter Adapter
|
||||
baseURL string
|
||||
@@ -51,7 +64,10 @@ type Client struct {
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewClient validates Adapter and base URL. apiKey may be empty.
|
||||
// NewClient checks adapter and baseURL.
|
||||
// It returns an error when adapter is not [AdapterOpenAI] or [AdapterOllama],
|
||||
// or when baseURL is empty or only whitespace.
|
||||
// A trailing slash on baseURL is removed. apiKey may be empty.
|
||||
func NewClient(adapter Adapter, baseURL, apiKey string) (*Client, error) {
|
||||
switch adapter {
|
||||
case AdapterOpenAI, AdapterOllama:
|
||||
@@ -65,7 +81,10 @@ func NewClient(adapter Adapter, baseURL, apiKey string) (*Client, error) {
|
||||
return &Client{adapter: adapter, baseURL: base, apiKey: apiKey, http: http.DefaultClient}, nil
|
||||
}
|
||||
|
||||
// Chat returns the assistant text for messages.
|
||||
// Chat sends messages and returns the assistant text.
|
||||
// It returns an error when ctx is canceled, the HTTP status is not 2xx,
|
||||
// the body cannot be decoded, or an OpenAI response contains no choices.
|
||||
// An HTTP failure includes a short prefix of the response body.
|
||||
func (c *Client) Chat(ctx context.Context, messages []Message, opt Options) (string, error) {
|
||||
switch c.adapter {
|
||||
case AdapterOpenAI:
|
||||
@@ -77,7 +96,13 @@ func (c *Client) Chat(ctx context.Context, messages []Message, opt Options) (str
|
||||
}
|
||||
}
|
||||
|
||||
// Stream yields assistant text chunks for the same Chat request.
|
||||
// Stream sends the same request as Chat with streaming enabled and calls
|
||||
// yield for each non-empty text chunk, in order.
|
||||
// It returns the error from yield.
|
||||
// It also returns an error when ctx is canceled, the HTTP status is not 2xx,
|
||||
// or a stream line cannot be decoded.
|
||||
// [AdapterOpenAI] ends the stream when a data line is the token DONE.
|
||||
// [AdapterOllama] reads one JSON object per line and yields message content.
|
||||
func (c *Client) Stream(ctx context.Context, messages []Message, opt Options, yield func(chunk string) error) error {
|
||||
switch c.adapter {
|
||||
case AdapterOpenAI:
|
||||
@@ -89,7 +114,11 @@ func (c *Client) Stream(ctx context.Context, messages []Message, opt Options, yi
|
||||
}
|
||||
}
|
||||
|
||||
// ListModels returns server-side Model names.
|
||||
// ListModels returns model names reported by the server.
|
||||
// [AdapterOpenAI] reads data[].id from GET /v1/models.
|
||||
// [AdapterOllama] reads models[].name from GET /api/tags.
|
||||
// It returns an error when ctx is canceled, the HTTP status is not 2xx,
|
||||
// or the body cannot be decoded. Empty names are omitted.
|
||||
func (c *Client) ListModels(ctx context.Context) ([]string, error) {
|
||||
switch c.adapter {
|
||||
case AdapterOpenAI:
|
||||
@@ -276,4 +305,3 @@ func truncate(b []byte) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
# API
|
||||
|
||||
Generated from the package comment and every exported declaration. Do not edit.
|
||||
Regenerate with `UPDATE_API_DOCS=1 go test -run TestAPIDoc -count=1`.
|
||||
|
||||
## Package gollm
|
||||
|
||||
Package gollm is a chat LLM wire client: Chat, Stream, and List Models through an Ollama-native or OpenAI-compatible Adapter.
|
||||
|
||||
## Types
|
||||
|
||||
### Adapter
|
||||
|
||||
```go
|
||||
type Adapter string
|
||||
```
|
||||
|
||||
Adapter selects the wire protocol a Client speaks. [AdapterOpenAI](#adapteropenai) uses POST /v1/chat/completions and GET /v1/models. [AdapterOllama](#adapterollama) uses POST /api/chat and GET /api/tags.
|
||||
|
||||
#### AdapterOpenAI
|
||||
|
||||
#### AdapterOllama
|
||||
|
||||
```go
|
||||
const (
|
||||
// AdapterOpenAI selects the OpenAI-compatible protocol.
|
||||
AdapterOpenAI Adapter = "openai"
|
||||
// AdapterOllama selects the Ollama native protocol.
|
||||
AdapterOllama Adapter = "ollama"
|
||||
)
|
||||
```
|
||||
|
||||
### Client
|
||||
|
||||
```go
|
||||
type Client struct {
|
||||
// contains filtered or unexported fields
|
||||
}
|
||||
```
|
||||
|
||||
Client calls one Adapter at one base URL. An empty API key sends no Authorization header. A non-empty key is sent as a Bearer token on every request.
|
||||
|
||||
#### NewClient
|
||||
|
||||
```go
|
||||
func NewClient(adapter Adapter, baseURL, apiKey string) (*Client, error)
|
||||
```
|
||||
|
||||
NewClient checks adapter and baseURL. It returns an error when adapter is not [AdapterOpenAI](#adapteropenai) or [AdapterOllama](#adapterollama), or when baseURL is empty or only whitespace. A trailing slash on baseURL is removed. apiKey may be empty.
|
||||
|
||||
#### Client.Chat
|
||||
|
||||
```go
|
||||
func (c *Client) Chat(ctx context.Context, messages []Message, opt Options) (string, error)
|
||||
```
|
||||
|
||||
Chat sends messages and returns the assistant text. It returns an error when ctx is canceled, the HTTP status is not 2xx, the body cannot be decoded, or an OpenAI response contains no choices. An HTTP failure includes a short prefix of the response body.
|
||||
|
||||
#### Client.ListModels
|
||||
|
||||
```go
|
||||
func (c *Client) ListModels(ctx context.Context) ([]string, error)
|
||||
```
|
||||
|
||||
ListModels returns model names reported by the server. [AdapterOpenAI](#adapteropenai) reads data\[].id from GET /v1/models. [AdapterOllama](#adapterollama) reads models\[].name from GET /api/tags. It returns an error when ctx is canceled, the HTTP status is not 2xx, or the body cannot be decoded. Empty names are omitted.
|
||||
|
||||
#### Client.Stream
|
||||
|
||||
```go
|
||||
func (c *Client) Stream(ctx context.Context, messages []Message, opt Options, yield func(chunk string) error) error
|
||||
```
|
||||
|
||||
Stream sends the same request as Chat with streaming enabled and calls yield for each non-empty text chunk, in order. It returns the error from yield. It also returns an error when ctx is canceled, the HTTP status is not 2xx, or a stream line cannot be decoded. [AdapterOpenAI](#adapteropenai) ends the stream when a data line is the token DONE. [AdapterOllama](#adapterollama) reads one JSON object per line and yields message content.
|
||||
|
||||
### Message
|
||||
|
||||
```go
|
||||
type Message struct {
|
||||
Role Role
|
||||
Content string
|
||||
}
|
||||
```
|
||||
|
||||
Message is one chat turn. Role and Content are sent unchanged.
|
||||
|
||||
### Options
|
||||
|
||||
```go
|
||||
type Options struct {
|
||||
Model string
|
||||
Temperature *float64
|
||||
MaxTokens *int
|
||||
}
|
||||
```
|
||||
|
||||
Options configures one Chat or Stream call. Model is the server-side model name. The Client does not check that it is set; the server rejects an empty model. A nil Temperature or MaxTokens is left out of the request. On [AdapterOllama](#adapterollama), MaxTokens is sent as num\_predict.
|
||||
|
||||
### Role
|
||||
|
||||
```go
|
||||
type Role string
|
||||
```
|
||||
|
||||
Role is the speaker of one Message.
|
||||
|
||||
#### RoleSystem
|
||||
|
||||
#### RoleUser
|
||||
|
||||
#### RoleAssistant
|
||||
|
||||
```go
|
||||
const (
|
||||
// RoleSystem is a system instruction.
|
||||
RoleSystem Role = "system"
|
||||
// RoleUser is the caller's turn.
|
||||
RoleUser Role = "user"
|
||||
// RoleAssistant is a previous model turn.
|
||||
RoleAssistant Role = "assistant"
|
||||
)
|
||||
```
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
# go-llm guide
|
||||
|
||||
How to use the library. Symbol signatures and doc comments are in [API](api.md). Words for the domain are in [CONTEXT.md](../CONTEXT.md).
|
||||
|
||||
## Adapters
|
||||
|
||||
### Purpose
|
||||
|
||||
An adapter is the wire protocol a client speaks. There are two: Ollama native and OpenAI-compatible. One client is bound to one adapter, one base URL, and an optional API key.
|
||||
|
||||
### When to use it
|
||||
|
||||
Choose the adapter when constructing the client. Use the OpenAI adapter for any server that implements `/v1/chat/completions` and `/v1/models`. Use the Ollama adapter for Ollama's `/api/chat` and `/api/tags`.
|
||||
|
||||
### Call sequence
|
||||
|
||||
1. Pick `AdapterOpenAI` or `AdapterOllama`.
|
||||
2. Call `NewClient` with the adapter, the base URL, and the API key.
|
||||
3. Use that client for Chat, Stream, and List Models. The client adds the path for the adapter.
|
||||
|
||||
A trailing slash on the base URL is removed. An empty API key sends no `Authorization` header. A non-empty key is sent as `Bearer` on every request.
|
||||
|
||||
OpenAI paths are `POST /v1/chat/completions` and `GET /v1/models`. Ollama paths are `POST /api/chat` and `GET /api/tags`. On Ollama, max tokens are sent as `num_predict`.
|
||||
|
||||
### Errors
|
||||
|
||||
`NewClient` returns an error when the adapter is not one of the two values, or when the base URL is empty or only whitespace. It does not contact the server.
|
||||
|
||||
### Example
|
||||
|
||||
```go
|
||||
client, err := gollm.NewClient(gollm.AdapterOpenAI, "http://localhost:8000", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = client
|
||||
```
|
||||
|
||||
## Chat
|
||||
|
||||
### Purpose
|
||||
|
||||
Chat sends a list of messages and returns the assistant's text in one response.
|
||||
|
||||
### When to use it
|
||||
|
||||
Use Chat when the caller wants the full reply before continuing. Use Stream when the caller wants to show text as it arrives.
|
||||
|
||||
### Call sequence
|
||||
|
||||
1. Build a `[]Message`. Each message has a `Role` (`RoleSystem`, `RoleUser`, or `RoleAssistant`) and `Content`.
|
||||
2. Set `Options.Model` to the server-side model name.
|
||||
3. Set `Options.Temperature` or `Options.MaxTokens` only when the call should send them. A nil pointer is omitted. On the Ollama adapter, `MaxTokens` is sent as `num_predict`.
|
||||
4. Call `Client.Chat` with a context, the messages, and the options.
|
||||
5. Use the returned string as the assistant text.
|
||||
|
||||
The client does not check that `Model` is set. The server rejects an empty model.
|
||||
|
||||
### Errors
|
||||
|
||||
`Chat` returns an error when the context is canceled, the HTTP status is not 2xx, or the body cannot be decoded. An OpenAI response with no choices is also an error. An HTTP failure includes a short prefix of the response body.
|
||||
|
||||
### Example
|
||||
|
||||
```go
|
||||
temp := 0.2
|
||||
text, err := client.Chat(ctx, []gollm.Message{{
|
||||
Role: gollm.RoleUser,
|
||||
Content: "Hello",
|
||||
}}, gollm.Options{Model: "llama", Temperature: &temp})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = text
|
||||
```
|
||||
|
||||
## Stream
|
||||
|
||||
### Purpose
|
||||
|
||||
Stream sends the same request as Chat and delivers the assistant text as non-empty chunks, in order.
|
||||
|
||||
### When to use it
|
||||
|
||||
Use Stream when the caller wants incremental text. The request body matches Chat, with streaming turned on.
|
||||
|
||||
### Call sequence
|
||||
|
||||
1. Build messages and options the same way as Chat.
|
||||
2. Call `Client.Stream` with a `yield` function. `yield` receives each non-empty chunk.
|
||||
3. Return nil from `yield` to keep reading. Return an error from `yield` to stop the stream.
|
||||
|
||||
The OpenAI adapter ends the stream when a data line is the token DONE. The Ollama adapter reads one JSON object per line and yields `message.content`.
|
||||
|
||||
### Errors
|
||||
|
||||
`Stream` returns the error from `yield`. It also returns an error when the context is canceled, the HTTP status is not 2xx, or a stream line cannot be decoded.
|
||||
|
||||
### Example
|
||||
|
||||
```go
|
||||
var reply strings.Builder
|
||||
err := client.Stream(ctx, []gollm.Message{{
|
||||
Role: gollm.RoleUser,
|
||||
Content: "Hello",
|
||||
}}, gollm.Options{Model: "llama"}, func(chunk string) error {
|
||||
reply.WriteString(chunk)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
## List Models
|
||||
|
||||
### Purpose
|
||||
|
||||
List Models returns the model names the server reports.
|
||||
|
||||
### When to use it
|
||||
|
||||
Use it to discover names to put in `Options.Model`. The client does not cache the list.
|
||||
|
||||
### Call sequence
|
||||
|
||||
1. Use the same client as Chat.
|
||||
2. Call `Client.ListModels` with a context.
|
||||
3. Use the returned names. Empty names from the server are omitted.
|
||||
|
||||
The OpenAI adapter reads `data[].id` from `GET /v1/models`. The Ollama adapter reads `models[].name` from `GET /api/tags`.
|
||||
|
||||
### Errors
|
||||
|
||||
`ListModels` returns an error when the context is canceled, the HTTP status is not 2xx, or the body cannot be decoded.
|
||||
|
||||
### Example
|
||||
|
||||
```go
|
||||
names, err := client.ListModels(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = names
|
||||
```
|
||||
Reference in New Issue
Block a user