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

8.9 KB · 275 lines · Go Raw History
  1package main
  2
  3import (
  4	"bytes"
  5	"context"
  6	"encoding/json"
  7	"fmt"
  8	"net/http"
  9	"strings"
 10	"sync"
 11	"time"
 12)
 13
 14// LLM talks to llama-server's OpenAI compatible endpoint.
 15//
 16// The model runs in a separate container with the GPU attached, so this is
 17// always a network call even in development.
 18type LLM struct {
 19	BaseURL string
 20	Model   string
 21	Key     string
 22	client  *http.Client
 23
 24	// served is the model string llama.cpp answers with, which is the real
 25	// repository and quant rather than the "local" alias this asks for. It is
 26	// read off a response instead of configured, so a stamp on a logged answer
 27	// says what actually produced it even when the config changed underneath.
 28	mu     sync.Mutex
 29	served string
 30}
 31
 32func NewLLM(baseURL, key string) *LLM {
 33	return &LLM{
 34		BaseURL: strings.TrimRight(baseURL, "/"),
 35		Model:   "local",
 36		Key:     key,
 37		client:  &http.Client{Timeout: 4 * time.Minute},
 38	}
 39}
 40
 41// sign puts the gateway key on a request, and the incognito header when the
 42// question is one. An empty key means talking straight to a llama-swap with no
 43// gateway in front, which is what a bare development run is.
 44func (l *LLM) sign(r *http.Request) {
 45	if l.Key != "" {
 46		r.Header.Set("Authorization", "Bearer "+l.Key)
 47	}
 48	if IsIncognito(r.Context()) {
 49		r.Header.Set(incognitoHeader, "1")
 50	}
 51}
 52
 53// The gateway writes down every prompt and completion it forwards, so a
 54// question that leaves no history row here has to say so there as well.
 55const incognitoHeader = "X-Incognito"
 56
 57type incognitoKey struct{}
 58
 59// WithIncognito marks a context as belonging to an incognito question. It rides
 60// the context rather than the client because the client is shared by every
 61// question, and every step of the pipeline already carries the context.
 62func WithIncognito(ctx context.Context) context.Context {
 63	return context.WithValue(ctx, incognitoKey{}, true)
 64}
 65
 66func IsIncognito(ctx context.Context) bool {
 67	on, _ := ctx.Value(incognitoKey{}).(bool)
 68	return on
 69}
 70
 71type chatMessage struct {
 72	Role    string `json:"role"`
 73	Content string `json:"content"`
 74}
 75
 76type chatRequest struct {
 77	Model           string          `json:"model"`
 78	Messages        []chatMessage   `json:"messages"`
 79	Temperature     float64         `json:"temperature"`
 80	TopP            float64         `json:"top_p"`
 81	TopK            int             `json:"top_k"`
 82	MinP            float64         `json:"min_p"`
 83	PresencePenalty float64         `json:"presence_penalty"`
 84	MaxTokens       int             `json:"max_tokens"`
 85	Stream          bool            `json:"stream"`
 86	ResponseFormat  *responseFormat `json:"response_format,omitempty"`
 87	TemplateKwargs  map[string]any  `json:"chat_template_kwargs,omitempty"`
 88}
 89
 90// These numbers came off Qwen's model card and were kept when the model behind
 91// this became Ornith 1.5 9B, since they are the same recipe its publisher gives
 92// and the reasoning below is about the shape of each step rather than about any
 93// one model. This pipeline needs two of them rather than one setting for
 94// everything.
 95//
 96// A step handing over a JSON schema wants the likeliest token inside the
 97// grammar, since the schema is doing the deciding and creativity there is only
 98// a way to pick the wrong enum. Synthesis is the one step writing prose, and
 99// there the model card's own non-thinking numbers apply. The presence penalty
100// matters most: Qwen names it as the fix for the model repeating itself, which
101// is exactly the failure this site keeps hitting, a closing paragraph that says
102// the bullet list again in weaker words.
103//
104// Before this everything ran at temperature 0.2 with llama.cpp's defaults for
105// the rest, so prose was sampled almost greedily with nothing discouraging
106// repetition.
107// The min_p on the prose set is the one number here that is about the quant
108// rather than the model. Quantization damages the tail of the distribution
109// first, since that is where the least of the model's confidence lives, and a
110// floor cuts exactly that tail. Qwen say 0.0 because they are describing the
111// full precision weights. Unsloth say 0.01 on a quantized one.
112//
113// The constrained set leaves it at zero and takes no penalty at all. The
114// grammar is already refusing every token that would not parse, and a presence
115// penalty on JSON pushes against the braces and quotes that have to repeat for
116// the output to be valid.
117var (
118	exact = sampling{Temperature: 0.2, TopP: 0.8, TopK: 20}
119	prose = sampling{Temperature: 0.7, TopP: 0.8, TopK: 20, MinP: 0.01, PresencePenalty: 1.5}
120)
121
122type sampling struct {
123	Temperature     float64
124	TopP            float64
125	TopK            int
126	MinP            float64
127	PresencePenalty float64
128}
129
130type responseFormat struct {
131	Type       string         `json:"type"`
132	JSONSchema *schemaWrapper `json:"json_schema,omitempty"`
133}
134
135type schemaWrapper struct {
136	Name   string          `json:"name"`
137	Strict bool            `json:"strict"`
138	Schema json.RawMessage `json:"schema"`
139}
140
141type chatResponse struct {
142	Model   string `json:"model"`
143	Choices []struct {
144		Message struct {
145			Content   string `json:"content"`
146			Reasoning string `json:"reasoning_content"`
147		} `json:"message"`
148	} `json:"choices"`
149	Error *struct {
150		Message string `json:"message"`
151	} `json:"error"`
152}
153
154// Complete runs a free-form completion. Used only for the synthesis step.
155func (l *LLM) Complete(ctx context.Context, system, user string, maxTokens int) (string, error) {
156	return l.call(ctx, system, user, maxTokens, nil, prose)
157}
158
159// Structured constrains the model to a JSON schema. llama.cpp turns the schema
160// into a GBNF grammar and constrains sampling to it, so the model cannot emit a
161// citation ID outside the enum it was given, and cannot emit a refusal either.
162func (l *LLM) Structured(ctx context.Context, system, user string, maxTokens int, schema any, out any) error {
163	raw, err := json.Marshal(schema)
164	if err != nil {
165		return err
166	}
167	format := &responseFormat{
168		Type:       "json_schema",
169		JSONSchema: &schemaWrapper{Name: "response", Strict: true, Schema: raw},
170	}
171	text, err := l.call(ctx, system, user, maxTokens, format, exact)
172	if err != nil {
173		return err
174	}
175	text = strings.TrimSpace(text)
176	if i := strings.Index(text, "{"); i > 0 {
177		text = text[i:]
178	}
179	return json.Unmarshal([]byte(text), out)
180}
181
182func (l *LLM) call(ctx context.Context, system, user string, maxTokens int, format *responseFormat, s sampling) (string, error) {
183	body, err := json.Marshal(chatRequest{
184		Model:           l.Model,
185		Temperature:     s.Temperature,
186		TopP:            s.TopP,
187		TopK:            s.TopK,
188		MinP:            s.MinP,
189		PresencePenalty: s.PresencePenalty,
190		MaxTokens:       maxTokens,
191		ResponseFormat:  format,
192		// The model behind this is a thinking one and llama.cpp puts the chain
193		// of thought in reasoning_content, leaving content empty until the
194		// budget runs out. Every step here is either schema constrained or
195		// wants prose directly, so thinking only burns tokens.
196		TemplateKwargs: map[string]any{"enable_thinking": false},
197		Messages: []chatMessage{
198			{Role: "system", Content: system},
199			{Role: "user", Content: user},
200		},
201	})
202	if err != nil {
203		return "", err
204	}
205	req, err := http.NewRequestWithContext(ctx, "POST", l.BaseURL+"/v1/chat/completions", bytes.NewReader(body))
206	if err != nil {
207		return "", err
208	}
209	req.Header.Set("Content-Type", "application/json")
210	l.sign(req)
211
212	resp, err := l.client.Do(req)
213	if err != nil {
214		return "", err
215	}
216	defer resp.Body.Close()
217
218	var out chatResponse
219	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
220		return "", err
221	}
222	if out.Error != nil {
223		return "", fmt.Errorf("llm: %s", out.Error.Message)
224	}
225	if out.Model != "" {
226		l.mu.Lock()
227		l.served = out.Model
228		l.mu.Unlock()
229	}
230	if len(out.Choices) == 0 {
231		return "", fmt.Errorf("llm: no choices")
232	}
233	msg := out.Choices[0].Message
234	if msg.Content == "" && msg.Reasoning != "" {
235		return "", fmt.Errorf("llm: answered with reasoning only, thinking is not disabled")
236	}
237	return msg.Content, nil
238}
239
240// Warm fires a one token completion so llama-swap loads the model while the
241// search and the fetches are still in flight. The cold start then happens
242// inside time that was already being spent.
243func (l *LLM) Warm(ctx context.Context) {
244	ctx, cancel := context.WithTimeout(ctx, 90*time.Second)
245	defer cancel()
246	l.call(ctx, "", "hi", 1, nil, exact)
247}
248
249// Served is the model that answered last, empty until one has.
250func (l *LLM) Served() string {
251	l.mu.Lock()
252	defer l.mu.Unlock()
253	return l.served
254}
255
256// Healthy asks whether the model server is up, without waking the model.
257//
258// /v1/models answers from llama-swap's config and loads nothing. Asking /health
259// would risk pulling the weights back onto the card every time somebody opens
260// the page, which would quietly defeat the idle unload.
261func (l *LLM) Healthy(ctx context.Context) bool {
262	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
263	defer cancel()
264	req, err := http.NewRequestWithContext(ctx, "GET", l.BaseURL+"/v1/models", nil)
265	if err != nil {
266		return false
267	}
268	resp, err := l.client.Do(req)
269	if err != nil {
270		return false
271	}
272	defer resp.Body.Close()
273	return resp.StatusCode == http.StatusOK
274}