Files
llmgw/internal/kb/kbsync/api.go
T
Ilker ManapandClaude Opus 5.5 ba5df90602 llmgw-kb: sync a person's documents into a knowledge base from their own machine
Its own binary for Windows, macOS and Linux, carrying no server code. It reads
the files there, cuts them, has the gateway embed them with the person's key
and sends text and vectors for what changed only: a SQLite ledger on the
machine remembers each document's file, size, time, digest and what happened,
so a rerun reads nothing unchanged, a file skipped stays skipped until
--retry, and --delete removes only what this machine sent under the paths
given. --dry-run says what would leave the machine. Commands: login,
collections, sync, status, history, formats.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 13:43:47 +02:00

246 lines
7.0 KiB
Go

package kbsync
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
// Client speaks to a gateway's knowledge-base and embeddings endpoints with
// a person's key.
type Client struct {
// Base is the gateway's address, "https://llm.example.com".
Base string
Key string
// Project is the project a personal key acts in.
Project string
HTTP *http.Client
// Wait is how long a request waits out a model loading or a busy
// gateway (429/503 with Retry-After) before giving up; 0 is 15 minutes.
Wait time.Duration
// Waiting, when set, is told each time a request waits, and why.
Waiting func(why string, d time.Duration)
}
// NewHTTP is an HTTP client trusting the system's certificates, and those in
// caFile when one is given.
func NewHTTP(caFile string) (*http.Client, error) {
tr := http.DefaultTransport.(*http.Transport).Clone()
if caFile != "" {
pem, err := os.ReadFile(caFile)
if err != nil {
return nil, err
}
pool, err := x509.SystemCertPool()
if err != nil || pool == nil {
pool = x509.NewCertPool()
}
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("no certificate in %s", caFile)
}
tr.TLSClientConfig = &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}
}
return &http.Client{Transport: tr, Timeout: 10 * time.Minute}, nil
}
// Collection is a collection as the gateway shows it to this key.
type Collection struct {
ID string `json:"id"`
Name string `json:"name"`
Owner string `json:"owner"`
EmbedModel string `json:"embed_model"`
Dims int `json:"dims"`
Writable bool `json:"writable"`
}
// RemoteDoc is a document the collection holds.
type RemoteDoc struct {
Name string `json:"name"`
Digest string `json:"digest"`
Bytes int64 `json:"bytes"`
Chunks int `json:"chunks"`
AddedAt time.Time `json:"added_at"`
}
// Manifest is a collection and its documents.
type Manifest struct {
Collection Collection `json:"collection"`
Documents []RemoteDoc `json:"documents"`
}
// ChunkOut is one chunk as sent.
type ChunkOut struct {
Text string `json:"text"`
Embedding []float32 `json:"embedding"`
}
// DocOut is one document as sent.
type DocOut struct {
Name string `json:"name"`
Digest string `json:"digest"`
Bytes int64 `json:"bytes"`
Chunks []ChunkOut `json:"chunks"`
}
// APIError is an answer that was not a success.
type APIError struct {
Status int
Message string
}
func (e *APIError) Error() string { return fmt.Sprintf("%d: %s", e.Status, e.Message) }
// do sends a request and decodes a JSON answer into out, waiting out answers
// that say to retry.
func (c *Client) do(ctx context.Context, method, path string, body any, out any) error {
var payload []byte
if body != nil {
var err error
if payload, err = json.Marshal(body); err != nil {
return err
}
}
wait := c.Wait
if wait <= 0 {
wait = 15 * time.Minute
}
deadline := time.Now().Add(wait)
for {
req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(c.Base, "/")+path, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.Key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "llmgw-kb")
if c.Project != "" {
req.Header.Set("X-Llmgw-Project", c.Project)
}
res, err := c.HTTP.Do(req)
if err != nil {
return err
}
data, err := io.ReadAll(io.LimitReader(res.Body, 256<<20))
res.Body.Close()
if err != nil {
return err
}
if res.StatusCode < 300 {
if out == nil {
return nil
}
return json.Unmarshal(data, out)
}
retry := res.Header.Get("Retry-After")
if retry != "" && (res.StatusCode == http.StatusTooManyRequests || res.StatusCode >= 500) && time.Now().Before(deadline) {
secs, _ := strconv.Atoi(retry)
d := time.Duration(max(secs, 1)) * time.Second
if c.Waiting != nil {
c.Waiting(errorMessage(data, res.Status), d)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(d):
}
continue
}
return &APIError{Status: res.StatusCode, Message: errorMessage(data, res.Status)}
}
}
// errorMessage is the message in an OpenAI-shaped error, or the body.
func errorMessage(data []byte, status string) string {
var e struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
}
if json.Unmarshal(data, &e) == nil && e.Error.Message != "" {
return e.Error.Message
}
if s := strings.TrimSpace(string(data)); s != "" && len(s) < 500 {
return s
}
return status
}
// Collections lists what this key may read.
func (c *Client) Collections(ctx context.Context) ([]Collection, error) {
var out struct {
Data []Collection `json:"data"`
}
err := c.do(ctx, http.MethodGet, "/v1/kb/collections", nil, &out)
return out.Data, err
}
// Manifest reads a collection's documents, by its name or id.
func (c *Client) Manifest(ctx context.Context, collection string) (Manifest, error) {
var m Manifest
err := c.do(ctx, http.MethodGet, "/v1/kb/collections/"+url.PathEscape(collection)+"/manifest", nil, &m)
return m, err
}
// Put sends a document; changed is false when the collection already had it.
func (c *Client) Put(ctx context.Context, collection string, d DocOut) (bool, error) {
var out struct {
Changed bool `json:"changed"`
}
err := c.do(ctx, http.MethodPut, "/v1/kb/collections/"+url.PathEscape(collection)+"/documents", d, &out)
return out.Changed, err
}
// Delete removes a document; one already gone is not an error.
func (c *Client) Delete(ctx context.Context, collection, name string) error {
err := c.do(ctx, http.MethodDelete, "/v1/kb/collections/"+url.PathEscape(collection)+"/documents?name="+url.QueryEscape(name), nil, nil)
var ae *APIError
if errors.As(err, &ae) && ae.Status == http.StatusNotFound && strings.HasPrefix(ae.Message, "no document") {
return nil
}
return err
}
// embedBatch is how many chunks go in one embeddings request.
const embedBatch = 32
// Embed has the gateway embed texts with a model, in batches, in order.
func (c *Client) Embed(ctx context.Context, model string, texts []string) ([][]float32, error) {
out := make([][]float32, 0, len(texts))
for start := 0; start < len(texts); start += embedBatch {
end := min(start+embedBatch, len(texts))
var res struct {
Data []struct {
Index int `json:"index"`
Embedding []float32 `json:"embedding"`
} `json:"data"`
}
if err := c.do(ctx, http.MethodPost, "/v1/embeddings",
map[string]any{"model": model, "input": texts[start:end], "encoding_format": "float"}, &res); err != nil {
return nil, err
}
if len(res.Data) != end-start {
return nil, fmt.Errorf("the gateway embedded %d of %d texts", len(res.Data), end-start)
}
batch := make([][]float32, end-start)
for _, d := range res.Data {
if d.Index < 0 || d.Index >= len(batch) {
return nil, fmt.Errorf("an embedding came back with index %d", d.Index)
}
batch[d.Index] = d.Embedding
}
out = append(out, batch...)
}
return out, nil
}