orchard
mirrorEvery 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
1// The key ring and the call log.
2//
3// One file, two tables, and the same reasoning chat's history uses: this holds
4// the full text of every prompt and every completion the estate has produced,
5// so it is its own database rather than tables beside anything else, and
6// deleting it is one command that touches nothing else. secure_delete is on, so
7// a deleted call is zeroed rather than left in the free pages.
8package main
9
10import (
11 "crypto/rand"
12 "crypto/sha256"
13 "crypto/subtle"
14 "database/sql"
15 "encoding/base64"
16 "encoding/hex"
17 "errors"
18 "log/slog"
19 "os"
20 "path/filepath"
21 "strings"
22 "time"
23
24 _ "modernc.org/sqlite"
25)
26
27type Store struct{ db *sql.DB }
28
29const schema = `
30CREATE TABLE IF NOT EXISTS keys (
31 id INTEGER PRIMARY KEY,
32 name TEXT NOT NULL,
33 -- SHA-256 and not Argon2id. A key is 32 bytes this service generated, so
34 -- there is nothing to brute force and no reason to spend 100ms of every
35 -- request proving it. Argon2id is for repos, where the secret is chosen by
36 -- a person.
37 hash TEXT NOT NULL UNIQUE,
38 -- The first characters, so a key is recognisable in a list without being
39 -- recoverable from one.
40 prefix TEXT NOT NULL,
41 created_at INTEGER NOT NULL,
42 last_used INTEGER NOT NULL DEFAULT 0,
43 revoked_at INTEGER NOT NULL DEFAULT 0
44);
45CREATE TABLE IF NOT EXISTS calls (
46 id INTEGER PRIMARY KEY,
47 key_id INTEGER NOT NULL DEFAULT 0,
48 caller TEXT NOT NULL DEFAULT '',
49 model TEXT NOT NULL DEFAULT '',
50 messages TEXT NOT NULL DEFAULT '[]',
51 completion TEXT NOT NULL DEFAULT '',
52 tools TEXT NOT NULL DEFAULT '',
53 prompt_tok INTEGER NOT NULL DEFAULT 0,
54 output_tok INTEGER NOT NULL DEFAULT 0,
55 decode_tps REAL NOT NULL DEFAULT 0,
56 ms INTEGER NOT NULL DEFAULT 0,
57 status INTEGER NOT NULL DEFAULT 0,
58 err TEXT NOT NULL DEFAULT '',
59 at INTEGER NOT NULL
60);
61CREATE INDEX IF NOT EXISTS calls_at ON calls(at DESC);
62CREATE INDEX IF NOT EXISTS calls_caller ON calls(caller, at DESC);
63`
64
65func OpenStore(path string) (*Store, error) {
66 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
67 return nil, err
68 }
69 db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=secure_delete(ON)&_pragma=foreign_keys(ON)")
70 if err != nil {
71 return nil, err
72 }
73 if _, err := db.Exec(schema); err != nil {
74 return nil, err
75 }
76 return &Store{db: db}, nil
77}
78
79// Close checkpoints the write ahead log first, so a container stopped with
80// SIGKILL rather than SIGTERM loses at most the call in flight.
81func (s *Store) Close() error {
82 if _, err := s.db.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`); err != nil {
83 slog.Warn("wal checkpoint", "err", err)
84 }
85 return s.db.Close()
86}
87
88func (s *Store) Checkpoint() {
89 if _, err := s.db.Exec(`PRAGMA wal_checkpoint(PASSIVE)`); err != nil {
90 slog.Debug("wal checkpoint", "err", err)
91 }
92}
93
94type Key struct {
95 ID int64 `json:"id"`
96 Name string `json:"name"`
97 Prefix string `json:"prefix"`
98 Created time.Time `json:"created"`
99 Used time.Time `json:"used,omitempty"`
100 Revoked bool `json:"revoked"`
101}
102
103// keyPrefix is on every key this service issues, so one found in a config file
104// is identifiable as belonging here rather than to some other provider.
105const keyPrefix = "orch-"
106
107// NewKey mints a key and returns the plaintext exactly once. Nothing keeps it,
108// so a lost key is reissued rather than recovered.
109func (s *Store) NewKey(name string) (string, Key, error) {
110 name = strings.TrimSpace(name)
111 if name == "" {
112 return "", Key{}, errors.New("a key needs a name saying what will use it")
113 }
114 raw := make([]byte, 32)
115 if _, err := rand.Read(raw); err != nil {
116 return "", Key{}, err
117 }
118 secret := keyPrefix + base64.RawURLEncoding.EncodeToString(raw)
119 sum := hashKey(secret)
120 now := time.Now().Unix()
121 r, err := s.db.Exec(`INSERT INTO keys(name, hash, prefix, created_at) VALUES(?,?,?,?)`,
122 name, sum, secret[:len(keyPrefix)+6], now)
123 if err != nil {
124 return "", Key{}, err
125 }
126 id, _ := r.LastInsertId()
127 return secret, Key{ID: id, Name: name, Prefix: secret[:len(keyPrefix)+6], Created: time.Unix(now, 0)}, nil
128}
129
130func hashKey(secret string) string {
131 sum := sha256.Sum256([]byte(secret))
132 return hex.EncodeToString(sum[:])
133}
134
135// Authenticate resolves a presented key. A revoked key is not a match, and the
136// comparison is constant time even though the lookup is by hash, because the
137// row is fetched by hash and then confirmed rather than trusted.
138func (s *Store) Authenticate(secret string) (Key, bool) {
139 if !strings.HasPrefix(secret, keyPrefix) {
140 return Key{}, false
141 }
142 sum := hashKey(secret)
143 var k Key
144 var created, used, revoked int64
145 var stored string
146 err := s.db.QueryRow(`SELECT id, name, prefix, hash, created_at, last_used, revoked_at FROM keys WHERE hash=?`, sum).
147 Scan(&k.ID, &k.Name, &k.Prefix, &stored, &created, &used, &revoked)
148 if err != nil {
149 return Key{}, false
150 }
151 if subtle.ConstantTimeCompare([]byte(stored), []byte(sum)) != 1 {
152 return Key{}, false
153 }
154 if revoked != 0 {
155 return Key{}, false
156 }
157 k.Created, k.Used = time.Unix(created, 0), time.Unix(used, 0)
158 return k, true
159}
160
161func (s *Store) TouchKey(id int64) {
162 if _, err := s.db.Exec(`UPDATE keys SET last_used=? WHERE id=?`, time.Now().Unix(), id); err != nil {
163 slog.Debug("touch key", "err", err)
164 }
165}
166
167func (s *Store) Keys() ([]Key, error) {
168 rows, err := s.db.Query(`SELECT id, name, prefix, created_at, last_used, revoked_at FROM keys ORDER BY revoked_at, id DESC`)
169 if err != nil {
170 return nil, err
171 }
172 defer rows.Close()
173 out := []Key{}
174 for rows.Next() {
175 var k Key
176 var created, used, revoked int64
177 if err := rows.Scan(&k.ID, &k.Name, &k.Prefix, &created, &used, &revoked); err != nil {
178 return nil, err
179 }
180 k.Created, k.Revoked = time.Unix(created, 0), revoked != 0
181 if used > 0 {
182 k.Used = time.Unix(used, 0)
183 }
184 out = append(out, k)
185 }
186 return out, rows.Err()
187}
188
189// Revoke is not a delete, so the call log keeps pointing at a real key and the
190// history of what a retired service asked for stays readable.
191func (s *Store) Revoke(id int64) error {
192 _, err := s.db.Exec(`UPDATE keys SET revoked_at=? WHERE id=? AND revoked_at=0`, time.Now().Unix(), id)
193 return err
194}
195
196func (s *Store) DeleteKey(id int64) error {
197 _, err := s.db.Exec(`DELETE FROM keys WHERE id=?`, id)
198 return err
199}
200
201// Call is one request through the gateway, prompt and completion included.
202type Call struct {
203 ID int64 `json:"id"`
204 KeyID int64 `json:"key_id"`
205 Caller string `json:"caller"`
206 Model string `json:"model"`
207 Messages string `json:"messages"`
208 Completion string `json:"completion"`
209 Tools string `json:"tools,omitempty"`
210 PromptTok int `json:"prompt_tokens"`
211 OutputTok int `json:"output_tokens"`
212 DecodeTPS float64 `json:"decode_tps"`
213 MS int64 `json:"ms"`
214 Status int `json:"status"`
215 Err string `json:"err,omitempty"`
216 At time.Time `json:"at"`
217}
218
219func (s *Store) LogCall(c Call) {
220 _, err := s.db.Exec(`INSERT INTO calls
221 (key_id, caller, model, messages, completion, tools, prompt_tok, output_tok, decode_tps, ms, status, err, at)
222 VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
223 c.KeyID, c.Caller, c.Model, c.Messages, c.Completion, c.Tools,
224 c.PromptTok, c.OutputTok, c.DecodeTPS, c.MS, c.Status, c.Err, time.Now().Unix())
225 if err != nil {
226 slog.Error("logging a call failed", "err", err)
227 }
228}
229
230func (s *Store) Calls(caller string, limit int) ([]Call, error) {
231 q := `SELECT id, key_id, caller, model, messages, completion, tools,
232 prompt_tok, output_tok, decode_tps, ms, status, err, at
233 FROM calls`
234 args := []any{}
235 if caller != "" {
236 q += ` WHERE caller=?`
237 args = append(args, caller)
238 }
239 q += ` ORDER BY id DESC LIMIT ?`
240 args = append(args, limit)
241
242 rows, err := s.db.Query(q, args...)
243 if err != nil {
244 return nil, err
245 }
246 defer rows.Close()
247 out := []Call{}
248 for rows.Next() {
249 var c Call
250 var at int64
251 if err := rows.Scan(&c.ID, &c.KeyID, &c.Caller, &c.Model, &c.Messages, &c.Completion,
252 &c.Tools, &c.PromptTok, &c.OutputTok, &c.DecodeTPS, &c.MS, &c.Status, &c.Err, &at); err != nil {
253 return nil, err
254 }
255 c.At = time.Unix(at, 0)
256 out = append(out, c)
257 }
258 return out, rows.Err()
259}
260
261// Usage is the per caller roll up the overview page shows.
262type Usage struct {
263 Caller string `json:"caller"`
264 Calls int `json:"calls"`
265 PromptTok int `json:"prompt_tokens"`
266 OutputTok int `json:"output_tokens"`
267 Errors int `json:"errors"`
268 AvgTPS float64 `json:"avg_tps"`
269}
270
271func (s *Store) Usage(since time.Time) ([]Usage, error) {
272 rows, err := s.db.Query(`
273 SELECT caller, COUNT(*), COALESCE(SUM(prompt_tok),0), COALESCE(SUM(output_tok),0),
274 COALESCE(SUM(CASE WHEN status >= 400 OR err <> '' THEN 1 ELSE 0 END),0),
275 COALESCE(AVG(NULLIF(decode_tps,0)),0)
276 FROM calls WHERE at >= ? GROUP BY caller ORDER BY COUNT(*) DESC`, since.Unix())
277 if err != nil {
278 return nil, err
279 }
280 defer rows.Close()
281 out := []Usage{}
282 for rows.Next() {
283 var u Usage
284 if err := rows.Scan(&u.Caller, &u.Calls, &u.PromptTok, &u.OutputTok, &u.Errors, &u.AvgTPS); err != nil {
285 return nil, err
286 }
287 out = append(out, u)
288 }
289 return out, rows.Err()
290}
291
292// Prune drops calls older than the retention window. The prompts are the whole
293// of what anyone asked this estate, so they do not accumulate forever by
294// default.
295func (s *Store) Prune(keep time.Duration) (int64, error) {
296 r, err := s.db.Exec(`DELETE FROM calls WHERE at < ?`, time.Now().Add(-keep).Unix())
297 if err != nil {
298 return 0, err
299 }
300 return r.RowsAffected()
301}
302
303func (s *Store) Counts() (keys, calls int) {
304 _ = s.db.QueryRow(`SELECT COUNT(*) FROM keys WHERE revoked_at=0`).Scan(&keys)
305 _ = s.db.QueryRow(`SELECT COUNT(*) FROM calls`).Scan(&calls)
306 return
307}