repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

13.3 KB · 412 lines · Go Raw History
  1// The model client. llama.cpp behind llama-swap speaks the OpenAI chat
  2// completions shape, so this is that shape and nothing more.
  3package main
  4
  5import (
  6	"bufio"
  7	"bytes"
  8	"context"
  9	"encoding/json"
 10	"fmt"
 11	"io"
 12	"net/http"
 13	"strings"
 14	"time"
 15)
 16
 17type Role string
 18
 19const (
 20	RoleSystem    Role = "system"
 21	RoleUser      Role = "user"
 22	RoleAssistant Role = "assistant"
 23	RoleTool      Role = "tool"
 24)
 25
 26// ToolCall is one request from the model to run something.
 27type ToolCall struct {
 28	ID       string `json:"id,omitempty"`
 29	Type     string `json:"type,omitempty"`
 30	Function struct {
 31		Name      string `json:"name"`
 32		Arguments string `json:"arguments"`
 33	} `json:"function"`
 34}
 35
 36// Message is one entry in the conversation as the model sees it.
 37type Message struct {
 38	Role       Role       `json:"role"`
 39	Content    string     `json:"content"`
 40	ToolCalls  []ToolCall `json:"tool_calls,omitempty"`
 41	ToolCallID string     `json:"tool_call_id,omitempty"`
 42	Name       string     `json:"name,omitempty"`
 43}
 44
 45type LLM struct {
 46	BaseURL string
 47	Model   string
 48	Key     string
 49	http    *http.Client
 50}
 51
 52func NewLLM(base, model, key string) *LLM {
 53	if model == "" {
 54		model = "local"
 55	}
 56	return &LLM{
 57		BaseURL: strings.TrimRight(base, "/"),
 58		Model:   model,
 59		Key:     key,
 60		// Generation is slow and a long answer is normal, so this is generous.
 61		// The context deadline on the request is the real limit.
 62		http: &http.Client{Timeout: 10 * time.Minute},
 63	}
 64}
 65
 66type chatReq struct {
 67	Model           string           `json:"model"`
 68	Messages        []Message        `json:"messages"`
 69	Tools           []map[string]any `json:"tools,omitempty"`
 70	ToolChoice      string           `json:"tool_choice,omitempty"`
 71	Temperature     float64          `json:"temperature"`
 72	TopP            float64          `json:"top_p"`
 73	TopK            int              `json:"top_k"`
 74	MaxTokens       int              `json:"max_tokens"`
 75	Stream          bool             `json:"stream,omitempty"`
 76	StreamOptions   map[string]any   `json:"stream_options,omitempty"`
 77	TimingsPerToken bool             `json:"timings_per_token,omitempty"`
 78	Kwargs          map[string]any   `json:"chat_template_kwargs,omitempty"`
 79	ResponseFormat  *responseFormat  `json:"response_format,omitempty"`
 80}
 81
 82type responseFormat struct {
 83	Type       string         `json:"type"`
 84	JSONSchema *schemaWrapper `json:"json_schema,omitempty"`
 85}
 86
 87type schemaWrapper struct {
 88	Name   string          `json:"name"`
 89	Strict bool            `json:"strict"`
 90	Schema json.RawMessage `json:"schema"`
 91}
 92
 93type chatResp struct {
 94	Choices []struct {
 95		FinishReason string `json:"finish_reason"`
 96		Message      struct {
 97			Content   string     `json:"content"`
 98			Reasoning string     `json:"reasoning_content"`
 99			ToolCalls []ToolCall `json:"tool_calls"`
100		} `json:"message"`
101		Delta struct {
102			Content   string     `json:"content"`
103			Reasoning string     `json:"reasoning_content"`
104			ToolCalls []ToolCall `json:"tool_calls"`
105		} `json:"delta"`
106	} `json:"choices"`
107	Usage struct {
108		PromptTokens     int `json:"prompt_tokens"`
109		CompletionTokens int `json:"completion_tokens"`
110	} `json:"usage"`
111	// llama.cpp reports its own timings, which are the honest numbers: they
112	// exclude this client, the tools and the network.
113	Timings struct {
114		PromptN         int     `json:"prompt_n"`
115		PromptPerSecond float64 `json:"prompt_per_second"`
116		PredictedN      int     `json:"predicted_n"`
117		PredictedPerSec float64 `json:"predicted_per_second"`
118	} `json:"timings"`
119	Error any `json:"error"`
120}
121
122// Stats is what one turn cost, for the meter in the bar. Prompt is the whole
123// window the model was handed, so it is the number that says how full the
124// context is, not how long the last message was.
125type Stats struct {
126	Prompt     int     `json:"prompt_tokens"`
127	Completion int     `json:"completion_tokens"`
128	Decode     float64 `json:"decode_tps"`
129	Prefill    float64 `json:"prefill_tps"`
130}
131
132func (s *Stats) merge(o Stats) {
133	if o.Prompt > s.Prompt {
134		s.Prompt = o.Prompt
135	}
136	s.Completion += o.Completion
137	if o.Decode > 0 {
138		s.Decode = o.Decode
139	}
140	if o.Prefill > 0 {
141		s.Prefill = o.Prefill
142	}
143}
144
145// sampling is Qwen's published non-thinking recipe, which the other models here
146// are close enough to. Thinking is off because llama.cpp puts a chain of
147// thought in reasoning_content and leaves content empty, which does not look
148// like an error: a 200, well formed JSON, and nothing to show the user.
149func (l *LLM) base(msgs []Message, maxTok int) chatReq {
150	return chatReq{
151		Model: l.Model, Messages: msgs, Temperature: 0.7, TopP: 0.8, TopK: 20,
152		MaxTokens: maxTok, Kwargs: map[string]any{"enable_thinking": false},
153	}
154}
155
156// Complete asks for one turn, with tools offered. It does not stream, because a
157// turn that is going to call a tool has nothing to show yet.
158func (l *LLM) Complete(ctx context.Context, msgs []Message, schemas []map[string]any, maxTok int) (Message, error) {
159	m, _, err := l.CompleteStats(ctx, msgs, schemas, maxTok)
160	return m, err
161}
162
163// CompleteStats is Complete plus what the call cost.
164func (l *LLM) CompleteStats(ctx context.Context, msgs []Message, schemas []map[string]any, maxTok int) (Message, Stats, error) {
165	return l.complete(ctx, msgs, schemas, "auto", maxTok)
166}
167
168// CompleteRequiringTool is the same call with the model given no say in whether
169// it calls something. It is what a nudge turns into once the turn has already
170// asked politely: a model that has written a deferral will write another one,
171// and asking a third time spends the turn instead of answering it.
172func (l *LLM) CompleteRequiringTool(ctx context.Context, msgs []Message, schemas []map[string]any, maxTok int) (Message, Stats, error) {
173	return l.complete(ctx, msgs, schemas, "required", maxTok)
174}
175
176func (l *LLM) complete(ctx context.Context, msgs []Message, schemas []map[string]any, choice string, maxTok int) (Message, Stats, error) {
177	req := l.base(msgs, maxTok)
178	if len(schemas) > 0 {
179		req.Tools, req.ToolChoice = schemas, choice
180	}
181	var out chatResp
182	if err := l.post(ctx, req, &out); err != nil {
183		return Message{}, Stats{}, err
184	}
185	if len(out.Choices) == 0 {
186		return Message{}, Stats{}, fmt.Errorf("the model returned no choices")
187	}
188	st := statsOf(out)
189	c := out.Choices[0].Message
190	// Empty content beside a full chain of thought means enable_thinking did
191	// not take. Loud is better than a blank bubble.
192	if strings.TrimSpace(c.Content) == "" && len(c.ToolCalls) == 0 && strings.TrimSpace(c.Reasoning) != "" {
193		return Message{}, st, fmt.Errorf("the model returned only reasoning and no answer")
194	}
195	return Message{Role: RoleAssistant, Content: c.Content, ToolCalls: c.ToolCalls}, st, nil
196}
197
198// Structured constrains an answer to a JSON schema, which llama.cpp compiles to
199// a GBNF grammar and samples against, so a field declared as an enum cannot come
200// back as anything else. Temperature is low because these steps are decisions
201// rather than prose.
202func (l *LLM) Structured(ctx context.Context, msgs []Message, maxTok int, schema, out any) (Stats, error) {
203	raw, err := json.Marshal(schema)
204	if err != nil {
205		return Stats{}, err
206	}
207	req := l.base(msgs, maxTok)
208	req.Temperature = 0.2
209	req.ResponseFormat = &responseFormat{Type: "json_schema",
210		JSONSchema: &schemaWrapper{Name: "response", Strict: true, Schema: raw}}
211
212	var resp chatResp
213	if err := l.post(ctx, req, &resp); err != nil {
214		return Stats{}, err
215	}
216	if len(resp.Choices) == 0 {
217		return Stats{}, fmt.Errorf("the model returned no choices")
218	}
219	st := statsOf(resp)
220	text := strings.TrimSpace(resp.Choices[0].Message.Content)
221	// A model that opens with a word before the object is still constrained to
222	// emit one, so find it rather than failing the whole step.
223	if i := strings.Index(text, "{"); i > 0 {
224		text = text[i:]
225	}
226	return st, json.Unmarshal([]byte(text), out)
227}
228
229func statsOf(r chatResp) Stats {
230	s := Stats{Prompt: r.Usage.PromptTokens, Completion: r.Usage.CompletionTokens,
231		Decode: r.Timings.PredictedPerSec, Prefill: r.Timings.PromptPerSecond}
232	if s.Prompt == 0 {
233		s.Prompt = r.Timings.PromptN
234	}
235	if s.Completion == 0 {
236		s.Completion = r.Timings.PredictedN
237	}
238	return s
239}
240
241// Stream asks for the final answer and calls onDelta as text arrives. Tools are
242// deliberately not offered here: by this point the turn is answering.
243func (l *LLM) Stream(ctx context.Context, msgs []Message, maxTok int, onDelta func(string)) (string, Stats, error) {
244	req := l.base(msgs, maxTok)
245	req.Stream = true
246	// llama.cpp only reports its timings on a stream when asked.
247	req.StreamOptions = map[string]any{"include_usage": true}
248	req.TimingsPerToken = true
249	body, err := json.Marshal(req)
250	if err != nil {
251		return "", Stats{}, err
252	}
253	hr, err := http.NewRequestWithContext(ctx, "POST", l.BaseURL+"/v1/chat/completions", bytes.NewReader(body))
254	if err != nil {
255		return "", Stats{}, err
256	}
257	hr.Header.Set("Content-Type", "application/json")
258	l.sign(hr)
259	resp, err := l.http.Do(hr)
260	if err != nil {
261		return "", Stats{}, err
262	}
263	defer resp.Body.Close()
264	if resp.StatusCode >= 400 {
265		return "", Stats{}, modelError(resp.StatusCode, resp.Body)
266	}
267	var st Stats
268	var sb strings.Builder
269	sc := bufio.NewScanner(resp.Body)
270	sc.Buffer(make([]byte, 0, 64<<10), 4<<20)
271	for sc.Scan() {
272		line := strings.TrimSpace(sc.Text())
273		if !strings.HasPrefix(line, "data:") {
274			continue
275		}
276		data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
277		if data == "[DONE]" {
278			break
279		}
280		var ev chatResp
281		if json.Unmarshal([]byte(data), &ev) != nil {
282			continue
283		}
284		// The last chunk carries the usage and timings and no choices, which
285		// is why this is read before the choices check rather than after.
286		if got := statsOf(ev); got.Prompt > 0 || got.Decode > 0 || got.Completion > 0 {
287			st.merge(got)
288		}
289		if len(ev.Choices) == 0 {
290			continue
291		}
292		if d := ev.Choices[0].Delta.Content; d != "" {
293			sb.WriteString(d)
294			onDelta(d)
295		}
296	}
297	if err := sc.Err(); err != nil {
298		return sb.String(), st, err
299	}
300	return sb.String(), st, nil
301}
302
303// sign puts the gateway key on a request, and the incognito header when the
304// turn is one. An empty key means talking straight to a llama-swap with no
305// gateway in front, which is what a bare development run is.
306func (l *LLM) sign(r *http.Request) {
307	if l.Key != "" {
308		r.Header.Set("Authorization", "Bearer "+l.Key)
309	}
310	if IsIncognito(r.Context()) {
311		r.Header.Set(incognitoHeader, "1")
312	}
313}
314
315// The gateway writes down every prompt and completion it forwards, so a mode
316// that writes nothing down here has to say so there as well.
317const incognitoHeader = "X-Incognito"
318
319type incognitoKey struct{}
320
321// WithIncognito marks a context as belonging to an incognito turn. It rides the
322// context rather than the client because the client is shared by every turn,
323// and this way the gate, the compaction pass and the tools all inherit it.
324func WithIncognito(ctx context.Context) context.Context {
325	return context.WithValue(ctx, incognitoKey{}, true)
326}
327
328func IsIncognito(ctx context.Context) bool {
329	on, _ := ctx.Value(incognitoKey{}).(bool)
330	return on
331}
332
333func (l *LLM) post(ctx context.Context, req chatReq, out *chatResp) error {
334	body, err := json.Marshal(req)
335	if err != nil {
336		return err
337	}
338	hr, err := http.NewRequestWithContext(ctx, "POST", l.BaseURL+"/v1/chat/completions", bytes.NewReader(body))
339	if err != nil {
340		return err
341	}
342	hr.Header.Set("Content-Type", "application/json")
343	l.sign(hr)
344	resp, err := l.http.Do(hr)
345	if err != nil {
346		return fmt.Errorf("the model is not answering: %w", err)
347	}
348	defer resp.Body.Close()
349	if resp.StatusCode >= 400 {
350		return modelError(resp.StatusCode, resp.Body)
351	}
352	return json.NewDecoder(resp.Body).Decode(out)
353}
354
355// Warm fires a one token completion so llama-swap loads the weights while the
356// user is still typing. Asking /v1/models would not do it, since that answers
357// from config and loads nothing.
358func (l *LLM) Warm(ctx context.Context) {
359	ctx, cancel := context.WithTimeout(ctx, 90*time.Second)
360	defer cancel()
361	var out chatResp
362	_ = l.post(ctx, chatReq{Model: l.Model, MaxTokens: 1,
363		Messages: []Message{{Role: RoleUser, Content: "hi"}},
364		Kwargs:   map[string]any{"enable_thinking": false}}, &out)
365}
366
367// Healthy asks whether the server is up without waking the model, because
368// /v1/models answers from llama-swap's config and loads no weights. Never point
369// this at /health, which would defeat the idle unload.
370func (l *LLM) Healthy(ctx context.Context) bool {
371	ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
372	defer cancel()
373	req, err := http.NewRequestWithContext(ctx, "GET", l.BaseURL+"/v1/models", nil)
374	if err != nil {
375		return false
376	}
377	l.sign(req)
378	resp, err := l.http.Do(req)
379	if err != nil {
380		return false
381	}
382	defer resp.Body.Close()
383	return resp.StatusCode == 200
384}
385
386// modelError carries the reason back rather than the number. A bare "the model
387// answered 500" is unactionable, and the body always says whether it was the
388// context, the template or the request.
389func modelError(status int, body io.Reader) error {
390	raw, _ := io.ReadAll(io.LimitReader(body, 32<<10))
391	var e struct {
392		Error struct {
393			Message string `json:"message"`
394		} `json:"error"`
395	}
396	if json.Unmarshal(raw, &e) == nil && strings.TrimSpace(e.Error.Message) != "" {
397		return fmt.Errorf("the model refused this turn: %s", strings.TrimSpace(e.Error.Message))
398	}
399	if txt := strings.TrimSpace(string(raw)); txt != "" {
400		return fmt.Errorf("the model answered %d: %s", status, trimLine(txt, 300))
401	}
402	return fmt.Errorf("the model answered %d with no reason", status)
403}
404
405func trimLine(s string, n int) string {
406	s = strings.Join(strings.Fields(s), " ")
407	if len(s) > n {
408		return s[:n] + "..."
409	}
410	return s
411}