123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611 | // Package llm is a minimal client for OpenAI-compatible chat completion APIs,
// with streaming and tool calls.
package llm
import (
"bufio"
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/http/httputil"
"os"
"strings"
"time"
)
type Client struct {
BaseURL string // e.g. "http://localhost:8080/v1"
APIKey string // optional for local servers
Model string
Thinking bool // allow reasoning/thinking output on models that support it
// Sampling settings. Greedy decoding (temperature 0) makes many models,
// Qwen3 among them, degrade and loop. TopP and TopK are omitted when 0.
Temperature float64
TopP float64
TopK int
HTTP *http.Client
Logger *log.Logger // if non-nil, requests and replies are logged
// MaxTokens caps each reply, so a model stuck in a repetition loop cannot
// generate forever. It is sent as max_tokens (which on most servers also
// counts reasoning), and enforced locally on streamed output in case the
// server ignores it. 0 means no limit.
MaxTokens int
// ContextWindow is how many tokens the model can take in, prompt and
// reply together; 0 if unknown.
ContextWindow int
// What the server accepts besides the standard OpenAI fields, since
// some (OpenAI's own among them) reject a request with any field they
// do not know.
//
// ThinkingParam is how Thinking is sent: ThinkingTemplate, as
// enable_thinking both at the top level (SGLang) and in
// chat_template_kwargs (vLLM, llama.cpp), or "" for not at all.
ThinkingParam string
// NoSampling leaves out temperature, top_p and top_k, for servers that
// take only their own (OpenAI's reasoning models, current Claude models).
NoSampling bool
// MaxTokensParam names the field MaxTokens is sent in: "max_tokens", or
// "max_completion_tokens", which OpenAI's reasoning models require.
MaxTokensParam string
}
// ThinkingTemplate is the ThinkingParam that sends enable_thinking for the
// chat template.
const ThinkingTemplate = "chat_template_kwargs"
func New(baseURL, apiKey, model string) *Client {
return &Client{
BaseURL: strings.TrimSuffix(baseURL, "/"),
APIKey: apiKey,
Model: model,
// A redirect would carry the key (and the conversation) somewhere
// else: Go keeps the Authorization header for the same host name
// even on another port or over plain http.
HTTP: &http.Client{Timeout: 10 * time.Minute, CheckRedirect: func(req *http.Request, _ []*http.Request) error {
return fmt.Errorf("%w to %s; set base_url to where it leads", ErrRedirect, req.URL.Redacted())
}},
ThinkingParam: ThinkingTemplate,
MaxTokensParam: "max_tokens",
}
}
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
// Reasoning is the thinking that came before an assistant message, sent
// back so a server whose chat template keeps it can continue from it.
Reasoning string `json:"reasoning_content,omitempty"`
// ToolCalls are the calls an assistant message makes; ToolCallID ties a
// "tool" message's result to its call.
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
// ToolCall is one call of a tool the model made.
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type"` // "function"
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"` // JSON
} `json:"function"`
}
// Tool describes a function the model may call.
type Tool struct {
Name string
Description string
Parameters map[string]any // a JSON schema for the arguments object
}
// toolParam is a tool as sent in a request.
type toolParam struct {
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Parameters map[string]any `json:"parameters,omitempty"`
} `json:"function"`
}
type chatRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Temperature *float64 `json:"temperature,omitempty"`
TopP float64 `json:"top_p,omitempty"`
TopK int `json:"top_k,omitempty"` // llama.cpp, vLLM and MLX extension
// Controls reasoning/thinking output on models that support it (e.g. Qwen3),
// which slows responses considerably when enabled. SGLang reads the
// top-level field; vLLM and llama.cpp read it from chat_template_kwargs.
EnableThinking *bool `json:"enable_thinking,omitempty"`
ChatTemplateKwargs map[string]any `json:"chat_template_kwargs,omitempty"`
Stream bool `json:"stream,omitempty"`
StreamOptions *streamOptions `json:"stream_options,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
Tools []toolParam `json:"tools,omitempty"`
ToolChoice string `json:"tool_choice,omitempty"` // "auto" or "none"
}
// streamOptions asks a streaming server to report the tokens used, in a
// last chunk.
type streamOptions struct {
IncludeUsage bool `json:"include_usage"`
}
// Usage is the tokens a completion took, as the server counts them.
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
}
// valid reports whether the server sent plausible counts.
func (u *Usage) valid() bool {
return u != nil && u.PromptTokens > 0 && u.CompletionTokens >= 0
}
// ErrRedirect reports a server's redirect, which the client does not
// follow.
var ErrRedirect = errors.New("the server redirected the request, which blinker does not follow")
// ErrTruncated reports a reply cut off by the MaxTokens limit before the model
// finished. Complete returns the partial reply alongside it.
var ErrTruncated = errors.New("reply hit the length limit")
// bytesPerToken bounds the local fallback limit on streamed output: generous,
// so it only trips when the server ignored max_tokens.
const bytesPerToken = 8
// maxReplyBytes bounds a reply however MaxTokens is set, so a server that
// never stops cannot exhaust memory.
const maxReplyBytes = 64 << 20
type chatResponse struct {
Choices []struct {
Message Message `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage *Usage `json:"usage"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
// Result is one completion: the reply, the reasoning before it, and the
// tool calls it makes.
type Result struct {
Reply string
Reasoning string
Calls []ToolCall
Usage Usage // zero if the server did not say
}
// Message returns the result as the assistant message to add to the
// conversation.
func (r Result) Message() Message {
return Message{Role: "assistant", Content: r.Reply, Reasoning: r.Reasoning, ToolCalls: r.Calls}
}
// Complete makes one completion with the tools the model may call, streaming
// the reply to onToken (if non-nil) as it is generated; reasoning marks
// thinking output that is not part of the reply. Transient failures (network
// errors, 408/429/5xx) are retried with backoff, as long as nothing has been
// streamed yet, since a small local server may be busy loading the model or
// serving another request.
func (c *Client) Complete(ctx context.Context, messages []Message, tools []Tool, onToken func(text string, reasoning bool)) (Result, error) {
var lastErr error
for attempt := range 3 {
if attempt > 0 {
if c.Logger != nil {
c.Logger.Printf("retrying after error: %v", lastErr)
}
select {
case <-ctx.Done():
return Result{}, ctx.Err()
case <-time.After(time.Duration(attempt) * 5 * time.Second):
}
}
streamed := false
emit := func(text string, reasoning bool) {
streamed = true
if onToken != nil {
onToken(text, reasoning)
}
}
r, retryable, err := c.chatOnce(ctx, messages, tools, emit)
if err == nil {
// Some servers leave out a call's id, which its result must
// name; it need only be unique in the conversation.
for i := range r.Calls {
if r.Calls[i].ID == "" {
r.Calls[i].ID = fmt.Sprintf("call_%d_%d", len(messages), i)
}
r.Calls[i].Type = "function"
}
return r, nil
}
if !retryable || streamed || ctx.Err() != nil {
return r, err
}
lastErr = err
}
return Result{}, lastErr
}
// chatOnce makes one streaming request.
func (c *Client) chatOnce(ctx context.Context, messages []Message, tools []Tool, emit func(string, bool)) (r Result, retryable bool, err error) {
creq := chatRequest{
Model: c.Model,
Messages: messages,
Stream: true,
StreamOptions: &streamOptions{IncludeUsage: true},
}
if !c.NoSampling {
temp := c.Temperature
creq.Temperature, creq.TopP, creq.TopK = &temp, c.TopP, c.TopK
}
if c.ThinkingParam == ThinkingTemplate {
thinking := c.Thinking
creq.EnableThinking = &thinking
creq.ChatTemplateKwargs = map[string]any{"enable_thinking": thinking}
}
if c.MaxTokensParam == "max_completion_tokens" {
creq.MaxCompletionTokens = c.MaxTokens
} else {
creq.MaxTokens = c.MaxTokens
}
for _, t := range tools {
var p toolParam
p.Type = "function"
p.Function.Name, p.Function.Description, p.Function.Parameters = t.Name, t.Description, t.Parameters
creq.Tools = append(creq.Tools, p)
}
if len(tools) > 0 {
creq.ToolChoice = "auto"
}
body, err := json.Marshal(creq)
if err != nil {
return Result{}, false, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/chat/completions", bytes.NewReader(body))
if err != nil {
return Result{}, false, err
}
req.Header.Set("Content-Type", "application/json")
if c.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+c.APIKey)
}
if c.Logger != nil {
dump, err := httputil.DumpRequestOut(req, true)
if err != nil {
dump = []byte("dump failed: " + err.Error())
}
c.Logger.Printf("request:\n%s", redact(dump, c.APIKey))
}
start := time.Now()
resp, err := c.HTTP.Do(req)
if err != nil {
// A network error is worth retrying: the server may be down briefly.
return Result{}, !errors.Is(err, ErrRedirect), err
}
defer resp.Body.Close()
// A server that honors stream=true answers with server-sent events; some
// ignore it and send plain JSON, which falls through to the path below.
if resp.StatusCode == http.StatusOK &&
strings.Contains(resp.Header.Get("Content-Type"), "text/event-stream") {
return c.readStream(resp, start, emit)
}
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil {
return Result{}, true, err
}
if c.Logger != nil {
head, err := httputil.DumpResponse(resp, false) // body already read; append it below
if err != nil {
head = []byte("dump failed: " + err.Error() + "\n")
}
c.Logger.Printf("response in %v:\n%s%s", time.Since(start).Round(time.Millisecond), head, respBody)
}
// Timeouts, rate limiting, and server errors are worth retrying; other
// statuses (bad request, auth) will not get better on their own.
transient := resp.StatusCode == http.StatusRequestTimeout ||
resp.StatusCode == http.StatusTooManyRequests ||
resp.StatusCode >= 500
var parsed chatResponse
if err := json.Unmarshal(respBody, &parsed); err != nil {
return Result{}, transient, fmt.Errorf("API returned non-JSON response (status %d): %.200s", resp.StatusCode, respBody)
}
if parsed.Error != nil {
return Result{}, transient, fmt.Errorf("API error (status %d): %s", resp.StatusCode, parsed.Error.Message)
}
if resp.StatusCode != http.StatusOK {
return Result{}, transient, fmt.Errorf("API error: status %d: %.200s", resp.StatusCode, respBody)
}
if len(parsed.Choices) == 0 {
return Result{}, false, errors.New("API response contained no choices")
}
msg := parsed.Choices[0].Message
thinking, reply := SplitThinking(msg.Content)
if msg.Reasoning == "" {
msg.Reasoning = thinking
}
if msg.Reasoning != "" {
emit(msg.Reasoning, true)
}
if reply != "" {
emit(reply, false)
}
r = Result{Reply: reply, Reasoning: msg.Reasoning, Calls: msg.ToolCalls}
if parsed.Usage.valid() {
r.Usage = *parsed.Usage
}
if parsed.Choices[0].FinishReason == "length" {
return r, false, c.truncated()
}
return r, false, nil
}
// SplitThinking separates reasoning a server left inline in the reply as
// <think>...</think>, rather than sending it separately, from the reply.
// Some chat templates open the <think> tag in the prompt, so only the
// closing tag may appear.
func SplitThinking(reply string) (thinking, answer string) {
if i := strings.LastIndex(reply, "</think>"); i >= 0 {
thinking = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(reply[:i]), "<think>"))
return thinking, strings.TrimLeft(reply[i+len("</think>"):], " \t\r\n")
}
if t, ok := strings.CutPrefix(strings.TrimSpace(reply), "<think>"); ok {
return strings.TrimSpace(t), "" // cut off while still thinking
}
return "", reply
}
// truncated builds the error for a reply cut off at the length limit.
func (c *Client) truncated() error {
return fmt.Errorf("%w of %d tokens (raise max_tokens in agent.json, or with /config, if the model needs more room)", ErrTruncated, c.MaxTokens)
}
// streamChunk is one server-sent event of a streaming chat completion.
type streamChunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"` // vLLM, llama.cpp, SGLang
Reasoning string `json:"reasoning"` // the field name other servers use
// Tool calls arrive in pieces: the first names the call, and
// later ones with the same index add to its arguments.
ToolCalls []struct {
Index int `json:"index"`
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"delta"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage *Usage `json:"usage"` // in the last chunk, if asked for
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
// inlineSplitter routes reasoning a server leaves inline in the content, as
// <think>...</think>, to the reasoning side of the stream as it arrives.
type inlineSplitter struct {
emit func(string, bool)
pending strings.Builder // content not yet known to be reply or reasoning
state splitState
}
type splitState int
const (
atStart splitState = iota
inThink
inReply
)
func (s *inlineSplitter) write(text string) {
if s.state == inReply {
s.emit(text, false)
return
}
s.pending.WriteString(text)
buf := s.pending.String()
if s.state == atStart {
trimmed := strings.TrimLeft(buf, " \t\r\n")
switch {
case strings.HasPrefix(trimmed, "<think>"):
s.state = inThink
buf = strings.TrimPrefix(trimmed, "<think>")
case len(trimmed) < len("<think>") && strings.HasPrefix("<think>", trimmed):
return // could still become <think>
case strings.Contains(buf, "</think>"):
s.state = inThink // the template opened <think> in the prompt
default:
if trimmed == "" {
return
}
s.state = inReply
s.pending.Reset()
s.emit(buf, false)
return
}
}
// Inside reasoning: emit all but a possible partial "</think>".
if thought, rest, ok := strings.Cut(buf, "</think>"); ok {
if thought != "" {
s.emit(thought, true)
}
s.state = inReply
s.pending.Reset()
if rest = strings.TrimLeft(rest, " \t\r\n"); rest != "" {
s.emit(rest, false)
}
return
}
keep := 0
for k := len("</think>") - 1; k > 0; k-- {
if strings.HasSuffix(buf, "</think>"[:k]) {
keep = k
break
}
}
if len(buf) > keep {
s.emit(buf[:len(buf)-keep], true)
}
s.pending.Reset()
s.pending.WriteString(buf[len(buf)-keep:])
}
// readStream consumes a server-sent-event response, passing each token to
// emit as it arrives, and returns the assembled reply.
func (c *Client) readStream(resp *http.Response, start time.Time, emit func(string, bool)) (r Result, retryable bool, err error) {
var logOut io.Writer = io.Discard
if c.Logger != nil {
head, err := httputil.DumpResponse(resp, false)
if err != nil {
head = []byte("dump failed: " + err.Error() + "\n")
}
c.Logger.Printf("response streaming after %v:\n%s", time.Since(start).Round(time.Millisecond), head)
logOut = c.Logger.Writer()
}
var content, thought strings.Builder
var calls []ToolCall
var usage Usage
separate := false // the server sends reasoning in its own field
out := func(text string, reasoning bool) {
fmt.Fprint(logOut, text)
emit(text, reasoning)
}
split := &inlineSplitter{emit: out}
generated := 0 // bytes of reply, reasoning and calls, for the local length limits
limit := maxReplyBytes
if c.MaxTokens > 0 {
limit = min(limit, c.MaxTokens*bytesPerToken)
}
finished := false // [DONE] or a finish_reason came: the reply is whole
sc := bufio.NewScanner(resp.Body)
sc.Buffer(make([]byte, 0, 64<<10), 4<<20)
for sc.Scan() {
data, ok := strings.CutPrefix(sc.Text(), "data:")
if !ok {
continue // comment or blank separator line
}
data = strings.TrimSpace(data)
if data == "[DONE]" {
finished = true
break
}
var chunk streamChunk
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
continue // not a completion chunk (e.g. a keep-alive event)
}
if chunk.Error != nil {
fmt.Fprintln(logOut)
return Result{}, true, fmt.Errorf("API error mid-stream: %s", chunk.Error.Message)
}
if chunk.Usage.valid() {
usage = *chunk.Usage
}
if len(chunk.Choices) == 0 {
continue
}
delta := chunk.Choices[0].Delta
if delta.ReasoningContent == "" {
delta.ReasoningContent = delta.Reasoning
}
if delta.ReasoningContent != "" {
separate = true
out(delta.ReasoningContent, true)
thought.WriteString(delta.ReasoningContent)
}
generated += len(delta.ReasoningContent) + len(delta.Content)
for _, tc := range delta.ToolCalls {
generated += len(tc.ID) + len(tc.Function.Name) + len(tc.Function.Arguments)
if tc.Index < 0 || tc.Index > 128 {
continue
}
for len(calls) <= tc.Index {
calls = append(calls, ToolCall{Type: "function"})
}
call := &calls[tc.Index]
if tc.ID != "" {
call.ID = tc.ID
}
call.Function.Name += tc.Function.Name
call.Function.Arguments += tc.Function.Arguments
}
if delta.Content != "" {
if separate {
out(delta.Content, false)
} else {
split.write(delta.Content)
}
content.WriteString(delta.Content)
}
if chunk.Choices[0].FinishReason != "" {
finished = true
}
// Returning closes the response body, which stops generation on the
// server too.
if chunk.Choices[0].FinishReason == "length" || generated > limit {
fmt.Fprintln(logOut)
thinking, reply := SplitThinking(content.String())
return Result{Reply: reply, Reasoning: thought.String() + thinking}, false, c.truncated()
}
}
fmt.Fprintln(logOut)
if err := sc.Err(); err != nil {
return Result{}, true, fmt.Errorf("reading stream: %w", err)
}
if !finished {
// Cut off without saying so: its tool calls may be cut short too.
return Result{}, true, errors.New("the stream ended before the reply did")
}
if c.Logger != nil {
c.Logger.Printf("response complete in %v", time.Since(start).Round(time.Millisecond))
}
thinking, reply := SplitThinking(content.String())
return Result{Reply: reply, Reasoning: thought.String() + thinking, Calls: calls, Usage: usage}, false, nil
}
// redact hides the API key in logged output.
func redact(dump []byte, apiKey string) []byte {
if apiKey == "" {
return dump
}
return bytes.ReplaceAll(dump, []byte(apiKey), []byte("[redacted]"))
}
// TLSConfig builds the TLS settings for a server using a custom CA or
// self-signed certificate.
func TLSConfig(caCertPath string) (*tls.Config, error) {
if caCertPath == "" {
return &tls.Config{}, nil
}
pem, err := os.ReadFile(caCertPath)
if err != nil {
return nil, fmt.Errorf("reading ca-cert: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("no certificates found in %s", caCertPath)
}
return &tls.Config{RootCAs: pool}, nil
}
|