Compare commits
No files matched your search
@@ -39,6 +39,14 @@ jobs:
|
||||
- name: Build
|
||||
run: go build ./...
|
||||
|
||||
# llmgw-kb runs on people's own machines, Windows included, which no
|
||||
# test here runs on: building it for each catches a Unix-only call.
|
||||
- name: Build llmgw-kb for every system
|
||||
run: |
|
||||
for target in windows/amd64 windows/arm64 darwin/amd64 darwin/arm64 linux/amd64 linux/arm64; do
|
||||
CGO_ENABLED=0 GOOS="${target%/*}" GOARCH="${target#*/}" go build -o /dev/null ./cmd/llmgw-kb
|
||||
done
|
||||
|
||||
# The Terraform provider is a module of its own, which ./... above does
|
||||
# not reach. Its tests check its client against docs/admin-api.openapi.json,
|
||||
# so a change to the admin API that breaks the provider fails here. The
|
||||
|
||||
@@ -55,6 +55,21 @@ jobs:
|
||||
(cd dist && sha256sum "${name}" >> checksums.txt)
|
||||
done
|
||||
|
||||
# llmgw-kb, which people run on their own machines to put their
|
||||
# documents into a knowledge base: Windows too, which llmgw itself
|
||||
# does not build for.
|
||||
for target in windows/amd64 windows/arm64 darwin/amd64 darwin/arm64 linux/amd64 linux/arm64; do
|
||||
os="${target%/*}"; arch="${target#*/}"
|
||||
ext=""; [ "$os" = windows ] && ext=".exe"
|
||||
name="llmgw-kb-${version}-${os}-${arch}${ext}"
|
||||
echo "building $name"
|
||||
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build \
|
||||
-trimpath \
|
||||
-ldflags "-s -w -X gitea.mynodes.xyz/ilker/llmgw/internal/version.Version=${version}" \
|
||||
-o "dist/${name}" ./cmd/llmgw-kb
|
||||
(cd dist && sha256sum "${name}" >> checksums.txt)
|
||||
done
|
||||
|
||||
# The reverse proxy configurations, so a release page carries
|
||||
# everything install.md asks for and the repository is not needed.
|
||||
for f in deploy/proxy/Caddyfile deploy/proxy/nginx.conf; do
|
||||
@@ -91,6 +106,7 @@ jobs:
|
||||
# test the cross-compiled ones, which is why the native build is
|
||||
# checked rather than assumed.
|
||||
./dist/llmgw-${version}-linux-amd64 --version
|
||||
./dist/llmgw-kb-${version}-linux-amd64 --version
|
||||
cat dist/checksums.txt
|
||||
|
||||
# Signed, so a machine that trusts the key built into it installs
|
||||
|
||||
+217
-42
@@ -569,9 +569,12 @@ portal is not done — the page shows; the console and ctl change.
|
||||
client tests against a fake admin API and the OpenAPI document;
|
||||
acceptance tests with Terraform and OpenTofu against the fake; one
|
||||
apply, empty plan, in-place change and destroy against a real `llmgw
|
||||
server` run by hand. Left: organizations, teams, projects, people and
|
||||
keys are not in the admin API (they are `llmgw org`/`users`/`keys`
|
||||
against the store), so not in the provider; a model's definition beyond
|
||||
server` run by hand. Organizations, teams, projects, keys and
|
||||
memberships are in the admin API since 2026-10-08 (`/api/v1/orgs`,
|
||||
`teams`, `projects`, `keys`, `users/{id}/memberships`) and in the
|
||||
provider (`llmgw_org`, `llmgw_team`, `llmgw_project`, `llmgw_key`,
|
||||
`llmgw_membership`); accounts are still made by sign-up, invitation or
|
||||
OIDC/SCIM. Left: a model's definition beyond
|
||||
scaling, class, owner and adapters is changed by replacing it (the API
|
||||
has no edit, and `resources` is not used yet); `canary`, `slo`,
|
||||
`scaling.schedule` and `prewarm`, `guard` role and `origin` are not in
|
||||
@@ -793,10 +796,19 @@ leader 30s later with /health answering throughout; rollback did the same.
|
||||
facility state from the store (it kept what it read at start).
|
||||
- Retires `appendGuardIfMissing` (server init): the import does it.
|
||||
|
||||
### E2. Generated files from settings (L) — firewall on/off done 2026-10-06
|
||||
### E2. Generated files from settings (L) — firewall on/off done 2026-10-06, every machine 2026-10-09
|
||||
`firewall.enabled` in the cluster's settings reaches nodes over the agent
|
||||
stream and control planes through llmgw-firewall-sync.path. Left: the mirror
|
||||
machine and store members, the allowed set from membership, the other files.
|
||||
stream and control planes through llmgw-firewall-sync.path. Since
|
||||
2026-10-09 the mirror machine too (the leader PUTs /v1/firewall, the mirror
|
||||
writes `.firewall.json`, the same path unit applies it) and store members
|
||||
that run no llmgw (the leader keeps it in `cluster_state`, a root timer
|
||||
reads the member's own copy with psql); `firewall_networks` reach all of
|
||||
them, and the mirror lets in the network of every node and control plane
|
||||
the leader knows. Tried on the PoC: off and on, and a network added, on
|
||||
control planes, mirror, store member and nodes. Left: the allowed set
|
||||
replaced by membership rather than added to (a network taken out of the
|
||||
settings stays let in), the other files (the mirror's unit flags,
|
||||
keepalived, etcd and Patroni configuration).
|
||||
|
||||
Every machine runs the agent (root) with roles — node, control plane, mirror,
|
||||
store member — and the agent renders and applies what was written once by
|
||||
@@ -807,14 +819,34 @@ Patroni configuration. The firewall's allowed set is the cluster's membership
|
||||
removed changes every machine's rules. Retires `firewall.Admit` and
|
||||
`firewall_networks` in server.yaml (imported into the store).
|
||||
|
||||
### E3. agent.yaml down to bootstrap (M)
|
||||
### E3. agent.yaml down to bootstrap (M) — done 2026-10-08 (branch `settings-e`)
|
||||
agent.yaml keeps the control planes' addresses, its certificate and its id.
|
||||
Labels, fabrics, engines, heartbeat and supervisor settings move to the
|
||||
store per node and reach the agent over its stream; a change to the file is
|
||||
noticed and the agent restarts itself.
|
||||
Built as a section of the cluster's settings rather than a store of its own,
|
||||
so history, audit, `ctl config`, the console and the portal page cover it:
|
||||
`agents.default` and `agents.nodes.<id>` (config.AgentSettings; bootstrap
|
||||
keys node_id/server(s)/hostname/mgmt_addr/tls refused; each node's merge
|
||||
loaded as an agent would, so a bad fabric is refused on save). A live key:
|
||||
the control plane sends ControlMessage.AgentSettings on register and, a
|
||||
second apart per node, on change; the agent compares with what it started on,
|
||||
checks it loads beside its agent.yaml (else logs and keeps running), keeps it
|
||||
in /var/lib/llmgw-agent/agent-settings.yaml and restarts (engines adopted).
|
||||
At start: agent.yaml < kept settings < LLMGW_ env; kept settings that no
|
||||
longer load are set aside, never a reason not to start. agent.yaml is
|
||||
watched every 10 s and restarted on when it changes and loads. Left: file
|
||||
keys are not imported into the settings (they keep working under them);
|
||||
`node add` still writes a full agent.yaml; labels have two homes now
|
||||
(`ctl label`, which wins, and agents.*.labels).
|
||||
|
||||
### E4. Web settings page (S)
|
||||
### E4. Web settings page (S) — done 2026-10-08 (branch `settings-e`)
|
||||
The same document, edited on /admin/settings, for whoever has no terminal.
|
||||
Built: administrators only (staff roles get 404: webhook secrets), checked by
|
||||
the same function as PUT /api/v1/settings, made from a base version or
|
||||
refused on a newer one (the edit kept in the editor), history with view and
|
||||
roll back as a new version, audit with the size not the body, English and
|
||||
Turkish, a Settings link in the menu.
|
||||
|
||||
---
|
||||
|
||||
@@ -829,47 +861,140 @@ ACL yet); a personal key or session names a project or team and gets that
|
||||
chain only, never the union of its person's memberships; PostgreSQL store
|
||||
only, off on SQLite with a sentence saying why.
|
||||
|
||||
### F1. Store: collections, grants, chunks (M)
|
||||
- `pgvector` ≥ 0.8 installed on every Patroni member by `server init`
|
||||
(`internal/cli/serverinit_postgres.go`), `CREATE EXTENSION` in the
|
||||
migration; the SQLite migration has no tables, and the store reports the
|
||||
feature unavailable.
|
||||
- Tables: `kb_collections` (id, org_id, owner `team:…`/`project:…`/`user:…`,
|
||||
name, embed_model, dims, created_at), `kb_grants` (scope, scope_id,
|
||||
collection, perm read|write — like `guard_attachments`), `kb_documents`
|
||||
(name, digest, bytes, state, chunk count), `kb_chunks` (org_id,
|
||||
collection, doc_id, ord, body, tsv, embedding) `PARTITION BY LIST
|
||||
(org_id)`, a partition per organization, HNSW and GIN indexes per
|
||||
partition.
|
||||
- Row-level security on `kb_chunks` against `current_setting('llmgw.org')`
|
||||
and `llmgw.collections`, retrieval through its own role.
|
||||
- Rules, refused before anything is written: a grant never crosses
|
||||
organizations; owner and grants resolve to existing scopes; deleting a
|
||||
project/team/org and `users forget` delete their collections (`forget.go`).
|
||||
- Done: store tests on PostgreSQL, including an RLS test that a query
|
||||
without the settings returns no rows.
|
||||
### F1. Store: collections, grants, chunks (M) — done 2026-10-08 (branch `rag`)
|
||||
Built (`internal/store/knowledge.go`, migration 0035 PostgreSQL only):
|
||||
- `kb_collections` (owner scope/id, org, embed_model, dims), `kb_grants`
|
||||
(read|write, any scope, never across organizations; a key needs a
|
||||
project; a person needs a membership in the organization),
|
||||
`kb_documents` (digest: the same again is no change, a new one replaces
|
||||
in one transaction).
|
||||
- `kb_chunks` is created when the store opens, not in a migration (a
|
||||
migration needing an extension would stop the control plane; one that
|
||||
skipped it would never run again): partitioned by organization (hashed
|
||||
partition name), `tsv` generated (`simple`), GIN on the parent, one
|
||||
partial HNSW index per collection on its organization's partition
|
||||
(`embedding::vector(dims)`, `WHERE collection = '<id>'`). EXPLAIN on
|
||||
20k rows: partition pruned, that index used, policy applied.
|
||||
Later (scale, 2026-10-08): a partial GIN per collection too
|
||||
(`kb_tsv_<id>`, backfilled at open); `vec_type` per collection, `halfvec`
|
||||
on pgvector ≥ 0.7 (up to 4000 dims); `SET LOCAL hnsw.ef_search` to
|
||||
candidates(k) clamped 40..1000 (EXPLAIN: 40 rows without it, all with
|
||||
it); the text arm takes candidates by `ts_rank` in a subquery and scores
|
||||
only those.
|
||||
- RLS FORCEd on `kb_chunks` against `llmgw.kb_org`/`llmgw.kb_collections`
|
||||
(set with set_config per transaction), one role — the table owner — not
|
||||
a separate one. Chunks are deleted by the foreign-key cascade from their
|
||||
document, which RLS does not block.
|
||||
- `CollectionsFor(Effective, write)`: owner or grant anywhere on the chain;
|
||||
no organization (personal key without a project) reads nothing.
|
||||
- `SearchChunks`: per collection nearest-neighbour (literal id and cast so
|
||||
the partial index is used) plus a full-text arm with any word
|
||||
(`a | b`, lowercased by the database, not Go), fused by reciprocal rank.
|
||||
- Deleting a project/team/org that owns collections is refused like one
|
||||
with members (changed from "deleted with them": every other delete in the
|
||||
tree refuses); DeleteUser/ForgetUser delete the person's collections;
|
||||
deleting an organization drops its partition; grants to a deleted scope go.
|
||||
- `server init` installs `postgresql-N-pgvector` / `pgvector_N` and enables
|
||||
it; pgha installs it on every member at setup and enables it on the
|
||||
leader on every run. Neither stops on failure.
|
||||
Left: a cluster set up before (the PoC) gets no package from a rerun of
|
||||
pgha on a running cluster — install by hand or add an upgrade step;
|
||||
`server restore`/`CopyTo` do not carry kb tables (SQLite → PostgreSQL only,
|
||||
and SQLite has none); tests need `LLMGW_TEST_POSTGRES` pointing at a
|
||||
pgvector database as a non-superuser (a superuser bypasses RLS).
|
||||
|
||||
### F2. Ingest (M)
|
||||
- `llmgw ctl kb create|list|delete|grant|revoke|docs`, `ctl kb add
|
||||
<collection> <files…>`, admin API under `/api/v1/kb`, audit events.
|
||||
- Text from PDF (text layer), DOCX, HTML, Markdown, plain text — in Go, no
|
||||
Python. Split on headings/paragraphs, ~500 tokens with overlap. Embedded
|
||||
through the router at the bronze band, accounted to the collection's
|
||||
owner. One transaction per document; same digest skipped, changed digest
|
||||
replaces. OCR out of scope.
|
||||
- Console: a Knowledge screen (collections, documents, grants; add, grant,
|
||||
revoke, delete); `docs/console.md`, `docs/knowledge.md`.
|
||||
### F2. Ingest on the user's machine (L) — done 2026-10-08 (branch `rag`)
|
||||
Uploading whole files to the control plane was dropped: a PDF is 20–100×
|
||||
its text, and the work belongs where the files are. Instead a small client,
|
||||
`llmgw-kb`, run by the person on their own machine with their own key:
|
||||
- **Its own binary** (`cmd/llmgw-kb`), for Windows, macOS and Linux on amd64
|
||||
and arm64. `llmgw` itself does not build on Windows (agent syscalls), and
|
||||
employees' laptops should not carry the server, agent and setup screens. A
|
||||
test keeps server packages out of its imports.
|
||||
- `llmgw-kb sync <collection> <dir|file…> [--include/--exclude globs]
|
||||
[--dry-run] [--delete]`: scan → local catalog (path, size, mtime, sha256;
|
||||
cache under os.UserCacheDir, digests not recomputed for unchanged files)
|
||||
→ the collection's manifest from the gateway (name → digest only) → diff
|
||||
→ for new/changed files only: extract, chunk, embed through the gateway's
|
||||
`/v1/embeddings` with the person's key (its accounting, limits, guard and
|
||||
band apply), and send name, digest, chunk texts and vectors; `--delete`
|
||||
removes documents whose files are gone. `--dry-run` lists what would leave
|
||||
the machine and how much. A file that cannot be read (locked, unsupported,
|
||||
no text) is skipped with the reason; the run goes on.
|
||||
- **Formats, as many as possible.** Built in, pure Go: PDF (text layer),
|
||||
DOCX/XLSX/PPTX, ODT/ODS/ODP, RTF, EPUB, HTML/XHTML, EML (text and HTML
|
||||
parts), Jupyter notebooks, subtitles (SRT/VTT), CSV/TSV, JSON/YAML/XML,
|
||||
Markdown/RST/AsciiDoc/Org, plain text and source code; ZIP archives
|
||||
recursed into. With a helper found on the machine: legacy DOC/XLS/PPT, RTF
|
||||
and anything else LibreOffice opens (`soffice --headless --convert-to`),
|
||||
images and scanned PDFs through Tesseract (`tur+eng`). Text in UTF-8,
|
||||
UTF-16 (BOM) or Windows-1254/1252 is converted to UTF-8.
|
||||
- **Across systems:** document names relative to the synced root, `/`
|
||||
separated, Unicode NFC (macOS hands out NFD), a warning for two names that
|
||||
differ only in case; CRLF → LF; key from `LLMGW_API_KEY` or `llmgw-kb
|
||||
login` (os.UserConfigDir, 0600 where that means something); the system's
|
||||
CA store, `--ca` for another.
|
||||
- **Gateway endpoints** (client listener, person's key):
|
||||
`GET /v1/kb/collections` (what this key may read/write),
|
||||
`GET /v1/kb/collections/{id}/manifest`, `PUT
|
||||
/v1/kb/collections/{id}/documents` (one document per request: name,
|
||||
digest, bytes, chunks with text and embedding; dims checked, chunk count
|
||||
and size bounded), `DELETE …/documents?name=`. Write needs a write grant
|
||||
or ownership on the key's chain (`CollectionsFor(…, write)`); otherwise
|
||||
404.
|
||||
- **Administration** stays with operators: `llmgw ctl kb
|
||||
create|list|delete|grant|revoke|docs`, admin API `/api/v1/kb`, audit,
|
||||
console screen; `docs/knowledge.md`.
|
||||
- Trade-off accepted: vectors come from the client, so a writer could store
|
||||
vectors that do not match their text — only in collections they may
|
||||
already write.
|
||||
Built: `internal/kb/extract` (formats above; helpers found on PATH and in
|
||||
each system's install places), `internal/kb/chunk` (≈1800 characters under
|
||||
the heading path, a paragraph of overlap, sections kept apart once a chunk
|
||||
is a third full), `internal/kb/kbsync` (ledger in SQLite at
|
||||
os.UserConfigDir/llmgw-kb/ledger.db: documents keyed by gateway, collection
|
||||
and name with file, size, mtime, digest, state, reason; runs), `cmd/llmgw-kb`
|
||||
(login, collections, sync, status, history, formats), `internal/router/kb.go`
|
||||
(the four gateway endpoints, `X-Llmgw-Project` for personal keys, audit),
|
||||
`internal/control/adminapi/kb.go` + `llmgw ctl kb` + console Knowledge
|
||||
screen, `docs/knowledge.md`; CI builds the client for all six targets and
|
||||
releases attach it. Tested: every format from generated fixtures and a
|
||||
Turkish PDF (OCR path with Tesseract here), sync against a fake gateway and
|
||||
end to end against the router on pgvector.
|
||||
Tried on the PoC 2026-10-08 (control planes 140 and 150 on a dev build of
|
||||
this branch; their previous binary is /usr/local/bin/llmgw.pre-rag): Ubuntu's
|
||||
pgvector 0.6.0 installed on 149/150 and enabled; bge-m3's weights were never
|
||||
in the mirror (fetched from CompendiumLabs/bge-m3-gguf@1864f47f) and it is
|
||||
held at 1 replica; tree Demo/ik/belgeler and Demo/satis/crm with a key each;
|
||||
collection Demo/politikalar filled from a Mac through an SSH tunnel with ten
|
||||
Turkish files in nine formats (9 sent in 5 s, the .doc skipped for want of
|
||||
LibreOffice; a rerun sent nothing in 0 s; a changed file alone was sent).
|
||||
Nine Turkish questions each found their page first once search scored by
|
||||
similarity plus word coverage (rank fusion had got two wrong). The other
|
||||
team's key saw no collection and got 404 by id; with a read grant it read
|
||||
and could not write; revoked, nothing again. On the live store the llmgw
|
||||
role (not superuser, no BYPASSRLS) sees 0 chunks with no scope set and 0
|
||||
with the organization alone.
|
||||
Left: the gateway's collection list shows the owner as team:ID, not its
|
||||
path; no LibreOffice on the dev machine, so the legacy
|
||||
Office path is untested beyond its error; the client is unsigned (Gatekeeper
|
||||
and SmartScreen warn); Outlook `.msg` is not read; one document over ~60 MiB
|
||||
of JSON (a few thousand pages) is refused rather than split.
|
||||
PostgreSQL tests of store, router and adminapi share LLMGW_TEST_POSTGRES and
|
||||
the store's TRUNCATE each test; run them with `go test -p 1` or one package
|
||||
at a time, or a router test loses its rows mid-run.
|
||||
|
||||
### F3. Retrieval on the request path (M)
|
||||
### F3. Retrieval on the request path (M) — done 2026-10-08 (branch `rag`)
|
||||
- `llmgw.knowledge: [collections]` in the body (stripped before the
|
||||
engine), or default collections per project/key.
|
||||
- Allowed set from the caller's chain (§7.9.2), cached like limits; grants
|
||||
add up down the tree. Personal keys and sessions need `x-llmgw-project`
|
||||
(or the playground's picker) naming a scope the person is a member of.
|
||||
- A collection not allowed answers 404, exactly as a missing one.
|
||||
- Hybrid search with the filter inside the query (`hnsw.iterative_scan`,
|
||||
`WHERE org_id = $1 AND collection = ANY($2)`), full-text arm merged by
|
||||
reciprocal rank, `/v1/rerank` when a reranker is in the catalog. Top
|
||||
- Hybrid search with the filter inside the query (per collection, its own
|
||||
partial index; pgvector 0.6 is enough —
|
||||
scored by similarity plus the share of the question's words held (built
|
||||
in F1/F2 as SearchChunks), `/v1/rerank` when a reranker is in the catalog. Top
|
||||
chunks into a system message with document and position;
|
||||
`x-llmgw-sources` lists them.
|
||||
- Leaks closed: answer-cache key includes the allowed set; per-organization
|
||||
@@ -879,7 +1004,26 @@ only, off on SQLite with a sentence saying why.
|
||||
(including via cache), that a personal key without a project is refused,
|
||||
metrics (retrieval latency, chunks returned), docs.
|
||||
|
||||
### F4. Adapter training (L, after F1–F3)
|
||||
Built (`internal/router/rag.go`): `X-Llmgw-Knowledge` (header, as the
|
||||
other llmgw extensions are, not a body field), `X-Llmgw-Knowledge-K`;
|
||||
collections from the key's chain (kbEffective, shared with the kb
|
||||
endpoints), 404 for one not readable; the question embedded through the
|
||||
ordinary path on the caller's principal; SearchChunks; passages trimmed to
|
||||
the model's context; added to the one leading system message (or a new
|
||||
one); `x-llmgw-sources`; before guardInput; never cached; metrics. Default collections built (kb_defaults on org/team/project/key, narrowest
|
||||
wins, 30 s cache, `ctl kb default`, console action; never refuse); the
|
||||
portal chat's Documents picker (collection, default or none; sources listed
|
||||
under the answer; a header the page sends itself is dropped). `cache_salt` per organization on requests given passages, sent to vLLM
|
||||
replicas only (chosen replica's engine). Checked on the PoC 2026-10-08:
|
||||
vLLM 0.30.0 (cuda01) with TinyLlama-1.1B AWQ run by hand beside the 35B in
|
||||
the card's last 2.3 GB — a 668-token prompt reused 656 tokens under the same
|
||||
salt and 0 under another's, with identical text (llmgw cannot place it
|
||||
there: placement takes whole GPUs). Live on the PoC: a team default answers
|
||||
a request without the header, `none` turns it off, an explicit header with
|
||||
k=2 gives two sources and no more, llmgw-kb lists owners by path. Left: returned passages
|
||||
are not audited per request (the header and the kept request record them).
|
||||
|
||||
### F4. Adapter training (L, after F1–F3) — done 2026-10-08 (branch `adapters-f4`)
|
||||
A run-to-completion job placed by the control plane like a replica and
|
||||
supervised by the agent like an engine, in a Python environment the agent
|
||||
installs (TRL + PEFT, QLoRA). Data: Q/A pairs generated from a collection by
|
||||
@@ -887,6 +1031,37 @@ a cluster model through `/v1/batches`, reviewed by people. Output to the
|
||||
mirror, into the model's `adapters`, through canary and a quality suite.
|
||||
Runs when the scaling schedule frees a GPU; 8–14B dense bases on one card.
|
||||
Never train on a collection some users of the adapter may not read.
|
||||
Tried 2026-10-08 on the PoC, by hand: Qwen2.5-0.5B-Instruct (fp16) with a
|
||||
LoRA (r=16, every linear layer, 8.8M trainable), trained beside the 35B in
|
||||
cuda01's last 2.3 GB (peak 2158 MiB on the card, 64 s for 138 steps, PEFT in a
|
||||
venv over the vLLM engine's torch). Data: 90 Turkish Q/A pairs a cluster
|
||||
model wrote from the nine Demo/politikalar documents, through the gateway.
|
||||
On nine questions it had not seen, phrased otherwise: base 0/9, 6 epochs
|
||||
4/9, 4 epochs at a lower rate 2/9. Wrong answers mixed facts across
|
||||
documents (the meal card's amount, the office day). The same nine through
|
||||
retrieval: 9/9 with sources. So F4 is for format, vocabulary and tone, and
|
||||
facts come from F1–F3; a bigger base on a free card would do better but not
|
||||
change that. The training itself fits a small slot on a shared card, which
|
||||
the job design can rely on for small bases.
|
||||
|
||||
Built: `ctl kb dataset` (POST /api/v1/kb/collections/{id}/dataset, NDJSON
|
||||
with each pair's collection); train_jobs/train_artifacts (migrations 0037,
|
||||
0038 owner); internal/control/train (queue, one job per node, artifacts in
|
||||
1 MiB pieces, a node that goes fails its job); internal/agent/train (the
|
||||
newest vLLM's python3 + `uv pip install --no-deps --target` peft 0.21.2 and
|
||||
accelerate 1.15.0; llama.cpp's conversion code for the installed release,
|
||||
untarred from its source; the card with the most free memory, if enough;
|
||||
runs as llmgw-engine); `ctl train start|list|show --follow|cancel|rm|
|
||||
download|publish`; the mirror's PUT /v1/models/{model}/{version}/{file};
|
||||
publish uploads the GGUF (llama.cpp) or PEFT files (vLLM) and sets the
|
||||
adapter after checkAdapters. Owners: catalog.Adapter.Owner, enforced in the
|
||||
router (404, hidden from /v1/models); a job's owner defaults to its single
|
||||
collection's owner, must be able to read every collection the pairs came
|
||||
from, and pairs from a knowledge base are never public. Tried on the PoC:
|
||||
dataset of 90 pairs in 89 s; a job trained beside the 35B (peak 2214 MiB,
|
||||
138 steps in about a minute) and sent back PEFT and GGUF.
|
||||
Left: a job dies with its agent's stream (no resume); no console screen for
|
||||
training yet; quality gating of a published adapter is by hand (ctl eval).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+155
@@ -18,11 +18,166 @@ is only released once its **exit criterion** is met, not when its code lands.
|
||||
| v0.11.0 | — | High availability from setup, the setup and console screens, usage sums and per-tenant alerts | released |
|
||||
| v0.12.0 | — | The cluster's settings in the store, in force when changed; the firewall switch; wipe | released |
|
||||
| v0.13.0 | — | The whole system stopped and started from one command; Qwen3Guard judges, on a GPU and CPUs together | released |
|
||||
| v0.14.0 | — | Knowledge bases: documents from people's own machines, kept apart per tenant, answered from | released (v0.14.1: adapters, the tree over the API, the firewall on every machine) |
|
||||
| v1.0.0 | 5 | Tenancy, prefix affinity, benchmarking | planned |
|
||||
|
||||
Until v1.0.0 the minor version is the breaking one: configuration formats and Go
|
||||
APIs change between minors without a deprecation period.
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v0.14.1 — 2026-10-09
|
||||
|
||||
Adapters trained on the nodes from a knowledge base and kept private to
|
||||
their owners; the cluster's and the agents' settings edited in one place;
|
||||
the tenancy tree and keys over the admin API and in Terraform; knowledge
|
||||
bases that hold up with many documents; the firewall setting on every
|
||||
machine.
|
||||
|
||||
**Upgrading.**
|
||||
- **Nodes** need this release to follow `agents.*` in the cluster's
|
||||
settings; until a node is upgraded its agent.yaml is what it runs on.
|
||||
- **A separate mirror machine and store members that run nothing else**
|
||||
follow the firewall setting once what reads it is installed: run `sudo
|
||||
llmgw mirror init` on the mirror again with the flags it was set up with,
|
||||
and `llmgw server init --patroni` again for the store's members. A control
|
||||
plane set up before v0.12.0 needs `sudo llmgw firewall sync --install`.
|
||||
- **The store's PostgreSQL memory** applies after each member is restarted,
|
||||
one at a time, the leader last; until then it runs as before.
|
||||
- The store gains tables and columns; nothing to do. Existing knowledge-base
|
||||
collections get their full-text index when a control plane starts, and
|
||||
keep `vector` storage; only new collections use `halfvec`, and only on
|
||||
pgvector 0.7 or later.
|
||||
- Cached knowledge-base passages in vLLM are made again once, under the new
|
||||
`cache_salt`.
|
||||
|
||||
### Added
|
||||
|
||||
- **The cluster's settings on the portal** (BACKLOG E4): `/admin/settings`
|
||||
for administrators. It edits the document `llmgw ctl config edit` opens:
|
||||
checked as a control plane would load it, refused when made from a
|
||||
version since replaced, with the history to view and roll back. Changes
|
||||
are audited by size, never by contents.
|
||||
- **The nodes' agent settings in the cluster's settings** (BACKLOG E3):
|
||||
`agents.default` and `agents.nodes.<id>` hold everything agent.yaml can
|
||||
say but the agent's id, control planes, address and certificate. They are
|
||||
sent to the agents as they change; an agent starts again on new settings,
|
||||
or on an edited agent.yaml, with its engines serving throughout. Settings
|
||||
that would not load are refused on save, and set aside on a node rather
|
||||
than keep its agent from starting. Nodes need this release to follow
|
||||
them.
|
||||
- **LoRA adapters trained on the nodes** (BACKLOG F4): `llmgw ctl kb dataset`
|
||||
writes question/answer pairs from a collection with a model of the
|
||||
cluster. `llmgw ctl train start` trains an adapter on a node with its
|
||||
installed vLLM's torch and a small overlay, on a card with enough memory
|
||||
free (beside an engine, if it leaves that much), converting to GGUF for
|
||||
llama.cpp. `llmgw ctl train publish` keeps it on the mirror (which now
|
||||
accepts uploads) and makes it the model's adapter.
|
||||
- **Adapters with owners.** An adapter's `owner` (org, team, project, user
|
||||
or key) is the only part of the tree that sees it; to anyone else it is a
|
||||
model that does not exist. An adapter trained on a knowledge base belongs
|
||||
to a part of the tree that may read it, and is never made public.
|
||||
- The knowledge bases' vLLM `cache_salt` follows the collections searched,
|
||||
not the organization, so two teams of one organization no longer share
|
||||
cached passages.
|
||||
- **Organizations, teams, projects, API keys and memberships in the admin
|
||||
API**: `/api/v1/orgs` (the tree), `POST /api/v1/teams` and `projects`,
|
||||
`PATCH` to rename or move, `DELETE` for what is empty; `/api/v1/keys` to
|
||||
issue (the key is in the answer, once), list and revoke; `/api/v1/users`
|
||||
and `/api/v1/users/{id}/memberships`. They were only `llmgw org`, `keys`
|
||||
and `users` on a control plane before. The Terraform provider manages them
|
||||
as `llmgw_org`, `llmgw_team`, `llmgw_project`, `llmgw_key` (the key in a
|
||||
sensitive `secret`) and `llmgw_membership`.
|
||||
- **The firewall setting reaches every machine.** A separate mirror and the
|
||||
store's members follow `firewall.enabled` and `firewall_networks` as nodes
|
||||
and control planes did: the leading control plane tells the mirror over
|
||||
its API, and keeps the state in the store for members that run no llmgw,
|
||||
which a timer on each reads. The mirror also lets in the network of every
|
||||
node the leader knows. `mirror init` and `server init --patroni` install
|
||||
what follows it; run them again on a cluster set up before.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The store's PostgreSQL is sized to its machine's memory.** Before, it ran
|
||||
on PostgreSQL's defaults (128MB cache) under Patroni, or on 96MB on one
|
||||
machine. Now each member gets a share of its own RAM, a smaller one where
|
||||
a control plane or agent shares the machine, and the share is read again
|
||||
every time `server init --patroni` runs. The new cache is applied by
|
||||
restarting one member at a time, the leader last.
|
||||
- **Knowledge-base searches hold up with many documents.** New collections
|
||||
store their index as `halfvec` where pgvector is 0.7 or later (half the
|
||||
memory, and embeddings up to 4000 dimensions instead of 2000); older
|
||||
collections, and clusters on pgvector 0.6, keep `vector`. Each collection
|
||||
has its own full-text index, as it has its own vector index, made for
|
||||
existing collections when the control plane starts. The text arm ranks by
|
||||
the cheap measure first and scores only those candidates.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A knowledge-base search asking for more than 10 passages
|
||||
(`X-Llmgw-Knowledge-K`) got at most 40 candidates per collection from the
|
||||
vector index, whatever it asked for: pgvector's `hnsw.ef_search` is now
|
||||
raised to the number wanted, up to 1000.
|
||||
- `llmgw server backup` and `restore` against a replicated store over TLS
|
||||
failed looking for `~/.postgresql/root.crt`: pg_dump was given
|
||||
`sslmode=verify-full` without the cluster's authority.
|
||||
|
||||
## v0.14.0 — 2026-10-08
|
||||
|
||||
Knowledge bases: a company's documents, put in by people from their own
|
||||
machines, kept apart per tenant, and answered from.
|
||||
|
||||
**Upgrading.**
|
||||
- **pgvector on a cluster set up before this release.** Knowledge bases
|
||||
need pgvector, which `server init` and the high-availability database
|
||||
setup now install, but a rerun on a running cluster does not:
|
||||
1. Install `postgresql-<version>-pgvector` on every database machine.
|
||||
2. As postgres, run `CREATE EXTENSION vector` in llmgw's database.
|
||||
3. Restart the control planes.
|
||||
Until then everything else works, and `llmgw ctl kb list` says why
|
||||
knowledge bases are off. On a SQLite store they stay off.
|
||||
- **An embedding model.** A collection needs a catalog model with
|
||||
`capabilities.embeddings`, for example bge-m3, whose weights are in the
|
||||
mirror.
|
||||
- **The client.** People download `llmgw-kb` for their system from this
|
||||
release.
|
||||
|
||||
### Added
|
||||
|
||||
- **Collections and who may read them.** A collection is owned by an
|
||||
organization, team, project or person. Grants add up down the tenancy
|
||||
tree and never cross an organization. A collection a key may not read is
|
||||
answered as one that does not exist. Chunks are kept in PostgreSQL with
|
||||
pgvector, partitioned by organization, with an HNSW index per collection,
|
||||
and row-level security on the table as a second line behind every
|
||||
query's own filter. A project, team or organization that owns collections
|
||||
is not deleted; a person's collections go with their account.
|
||||
- **`llmgw-kb`**, its own download for Windows, macOS and Linux, run by
|
||||
people with their own key. It reads documents where they are and sends
|
||||
text and vectors, never the files, for what changed only; a SQLite ledger
|
||||
on the machine remembers what it did.
|
||||
- It reads PDF, Word, Excel, PowerPoint, OpenDocument, RTF, EPUB, HTML,
|
||||
MHTML, mail with attachments, notebooks, subtitles, ZIP archives, and
|
||||
text and source code in any common encoding.
|
||||
- Legacy Office files are read with LibreOffice, and scanned pages with
|
||||
Tesseract, when those are installed.
|
||||
- `--dry-run` says what would leave the machine; `--delete` removes only
|
||||
what that machine sent.
|
||||
- **Answers from documents.** A chat request names collections in
|
||||
`X-Llmgw-Knowledge`, or its part of the tree has a default (`llmgw ctl kb
|
||||
default`). The best passages, chosen by meaning plus a little for the
|
||||
question's words, are added to the system message, numbered, and
|
||||
`x-llmgw-sources` says which were used. This happens before the
|
||||
guardrails judge the request, and the answer is never served from the
|
||||
answer cache. On vLLM replicas the organization's `cache_salt` keeps
|
||||
tenants' prefix caches apart. It works through the Responses and Messages
|
||||
APIs too.
|
||||
- **Administration**: `llmgw ctl kb` (create, list, grant, revoke, default,
|
||||
docs, rm, search, delete), `/api/v1/kb`, the console's Knowledge screen,
|
||||
and a Documents picker on the portal's chat page.
|
||||
- Metrics `llmgw_kb_retrievals_total` and `llmgw_kb_retrieval_seconds`;
|
||||
[docs/knowledge.md](docs/knowledge.md).
|
||||
|
||||
## v0.13.0 — 2026-10-08
|
||||
|
||||
The whole system stopped, started and restarted from one command, a
|
||||
|
||||
@@ -6,6 +6,7 @@ LDFLAGS := -X '$(MODULE)/internal/version.Version=$(VERSION)'
|
||||
|
||||
build:
|
||||
go build -ldflags "$(LDFLAGS)" -o bin/llmgw ./cmd/llmgw
|
||||
go build -ldflags "$(LDFLAGS)" -o bin/llmgw-kb ./cmd/llmgw-kb
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
A single-binary LLM gateway for clusters with multiple InfiniBand fabrics,
|
||||
NVLink domains and heterogeneous GPUs.
|
||||
|
||||
It serves LLMs running on your own GPU servers (vLLM / SGLang) behind one
|
||||
OpenAI-compatible endpoint. Design document: [llmgw-plan.md](llmgw-plan.md).
|
||||
It serves LLMs running on your own GPU and CPU servers (vLLM, SGLang,
|
||||
llama.cpp, TensorRT-LLM) behind one OpenAI- and Anthropic-compatible
|
||||
endpoint, and answers from your own documents. Design document: [llmgw-plan.md](llmgw-plan.md).
|
||||
|
||||
## Getting started
|
||||
|
||||
@@ -37,6 +38,9 @@ Releases track the roadmap phases; see [CHANGELOG.md](CHANGELOG.md).
|
||||
| v0.9.0 | — | Managing people and the tenancy tree | released |
|
||||
| v0.10.0 | — | InfiniBand fabrics and NVLink, doctor, node add, engines installed by the nodes | released |
|
||||
| v0.11.0 | — | High availability from setup, the setup and console screens, usage sums and per-tenant alerts | released |
|
||||
| v0.12.0 | — | The cluster's settings in the store, in force when changed; the firewall switch; wipe | released |
|
||||
| v0.13.0 | — | The whole system stopped and started from one command; Qwen3Guard judges, on a GPU and CPUs together | released |
|
||||
| v0.14.0 | — | Knowledge bases: documents from people's own machines, kept apart per tenant, answered from | released (v0.14.1: adapters, the tree over the API, the firewall on every machine) |
|
||||
| v1.0.0 | 5 | Tenancy, prefix affinity, benchmarking | planned |
|
||||
|
||||
As of v0.6.0 a cluster runs unattended: agents report their hardware, the
|
||||
@@ -44,38 +48,67 @@ control plane places replicas — including ones larger than a single machine
|
||||
models load when someone asks for them and stop when nobody does, and keys,
|
||||
budgets and rate limits decide who may use any of it.
|
||||
|
||||
Since v0.11.0, not yet released (see [CHANGELOG.md](CHANGELOG.md),
|
||||
"Unreleased"):
|
||||
Since v0.11.0 (each release's detail is in [CHANGELOG.md](CHANGELOG.md)):
|
||||
|
||||
- **Security hardening** — signed releases only, a token on the admin API,
|
||||
engines not run as root, TLS for the agent port, etcd and Patroni, a
|
||||
firewall for llmgw's internal ports, encrypted backups, bounded
|
||||
connections, sign-in throttling, a Content-Security-Policy.
|
||||
- **Guardrails** — policies that check requests and answers (PII, secrets,
|
||||
prompt injection, deny patterns, a judge model such as Llama Guard), in
|
||||
observe or enforce mode, attached to any part of the tenancy tree:
|
||||
- **Adapters, and the tenancy tree over the API** (v0.14.1). `llmgw ctl
|
||||
kb dataset` and `llmgw ctl train` make a LoRA adapter from a collection
|
||||
on a node's spare GPU memory, kept on the mirror and seen only by its
|
||||
owner. Organizations, teams, projects, keys and memberships are in the
|
||||
admin API and the Terraform provider. The agents' settings are edited
|
||||
with the cluster's, on the portal too, and the firewall setting reaches
|
||||
every machine.
|
||||
- **Knowledge bases** (v0.14.0). People put a company's documents in from
|
||||
their own machines with `llmgw-kb`, a separate download for Windows,
|
||||
macOS and Linux that reads PDF, Office, OpenDocument, mail and many more
|
||||
formats and sends text and vectors, never the files. Collections are kept
|
||||
apart per tenant, down to row-level security in PostgreSQL. A chat
|
||||
request, or a part of the tree by default, is answered from them with
|
||||
its sources: [docs/knowledge.md](docs/knowledge.md).
|
||||
- **One command for the whole system** (v0.13.0): `llmgw stop`, `start` and
|
||||
`restart`. Qwen3Guard is the guardrails' judge, on a GPU and on CPUs
|
||||
together.
|
||||
- **Settings that apply when changed** (v0.12.0). The cluster's
|
||||
configuration lives in the store, the same on every control plane and in
|
||||
force within seconds (`llmgw ctl config`, the console's Settings). The
|
||||
firewall can be switched on and off for the whole cluster.
|
||||
- **Security hardening** (v0.11.2): signed releases only, a token on the
|
||||
admin API, engines not run as root, TLS for the agent port, etcd and
|
||||
Patroni, a firewall for llmgw's internal ports, encrypted backups,
|
||||
bounded connections, sign-in throttling, a Content-Security-Policy.
|
||||
- **Guardrails** (v0.11.2). Policies check requests and answers for PII,
|
||||
secrets, prompt injection, deny patterns, and with a judge model; they
|
||||
observe or enforce, and attach to any part of the tenancy tree:
|
||||
[docs/guardrails.md](docs/guardrails.md).
|
||||
- **Administration** — an audit log of every change (`ctl audit`), staff
|
||||
roles (viewer, operator, billing, admin), single sign-on through OpenID
|
||||
Connect and provisioning through SCIM 2.0, key lifetime limits and
|
||||
rotation with overlap (`keys ttl`, `keys rotate`), usage export as CSV
|
||||
(`ctl usage export`).
|
||||
- **Notifications** — webhooks, signed or Slack-shaped, for a model parked,
|
||||
a node lost, a budget spent and new weights published (`notify.webhooks`,
|
||||
`ctl notify test`), and Alertmanager receiver templates.
|
||||
- **Traffic and models** — request tags, canaries of new weights with
|
||||
automatic rollback, quality suites gating updates, a response cache,
|
||||
kept and replayed requests, batches, Anthropic's Messages API, more of
|
||||
OpenAI's (structured output, images, rerank, audio, moderations),
|
||||
scaling by the clock, SLO-aware admission, TensorRT-LLM, MIG, models
|
||||
imported from Ollama, routing by what each vLLM replica's cache holds,
|
||||
LoRA adapters loaded on demand, other llmgw clusters as providers, and
|
||||
placement that knows racks, PDUs and power caps.
|
||||
- **Operations** — `server restore` and off-machine backup copies, secrets
|
||||
in Vault, forgetting a person and keeping a project's requests in the
|
||||
cluster, offline installs from a bundle, a Helm chart, tracing from the
|
||||
control plane to the engines, the admin API in OpenAPI with a Terraform
|
||||
provider, and the portal in English and Turkish with its docs at /docs.
|
||||
- **Administration** (v0.11.2):
|
||||
- an audit log of every change (`ctl audit`);
|
||||
- staff roles (viewer, operator, billing, admin);
|
||||
- single sign-on through OpenID Connect and provisioning through SCIM
|
||||
2.0;
|
||||
- key lifetime limits, and rotation with overlap (`keys ttl`, `keys
|
||||
rotate`);
|
||||
- usage export as CSV (`ctl usage export`).
|
||||
- **Notifications** (v0.11.2): webhooks, signed or Slack-shaped, for a
|
||||
model parked, a node lost, a budget spent and new weights published, and
|
||||
Alertmanager receiver templates.
|
||||
- **Traffic and models** (v0.11.2):
|
||||
- request tags, canaries of new weights with automatic rollback, and
|
||||
quality suites gating updates;
|
||||
- a response cache, kept and replayed requests, and batches;
|
||||
- Anthropic's Messages API, and more of OpenAI's (structured output,
|
||||
images, rerank, audio, moderations);
|
||||
- scaling by the clock, and SLO-aware admission;
|
||||
- TensorRT-LLM, MIG, and models imported from Ollama;
|
||||
- routing by what each vLLM replica's cache holds, and LoRA adapters
|
||||
loaded on demand;
|
||||
- other llmgw clusters as providers, and placement that knows racks,
|
||||
PDUs and power caps.
|
||||
- **Operations** (v0.11.2):
|
||||
- `server restore`, off-machine backup copies, and secrets in Vault;
|
||||
- forgetting a person, and keeping a project's requests in the cluster;
|
||||
- offline installs from a bundle, and a Helm chart;
|
||||
- tracing from the control plane to the engines;
|
||||
- the admin API in OpenAPI, with a Terraform provider;
|
||||
- the portal in English and Turkish, with its docs at /docs.
|
||||
|
||||
Every one of these is in the console as well as on the command line:
|
||||
[docs/console.md](docs/console.md).
|
||||
@@ -124,6 +157,9 @@ Every page is also in the binary: `llmgw docs`, or **Manual** in the console.
|
||||
official OpenAI and Anthropic SDKs, and llmgw's extensions
|
||||
- [docs/guardrails.md](docs/guardrails.md) — checking requests and answers:
|
||||
policies, detectors, a judge model, and where each applies
|
||||
- [docs/knowledge.md](docs/knowledge.md) — knowledge bases: collections,
|
||||
who may read them, `llmgw-kb` on people's machines, and answers from
|
||||
documents
|
||||
- [docs/choosing-models.md](docs/choosing-models.md) — which models to run,
|
||||
how many of each, and where the weights should live
|
||||
|
||||
@@ -152,7 +188,7 @@ Every page is also in the binary: `llmgw docs`, or **Manual** in the console.
|
||||
## Building
|
||||
|
||||
```sh
|
||||
make build # bin/llmgw
|
||||
make build # bin/llmgw and bin/llmgw-kb
|
||||
make test # unit tests
|
||||
make race # with the race detector
|
||||
```
|
||||
@@ -169,6 +205,7 @@ llmgw ctl cordon gpu-a-01 # stop new placement, leave what runs
|
||||
sudo llmgw doctor # on a node: what its GPUs, NVLink and fabric need
|
||||
llmgw ctl audit --since 24h # who changed what
|
||||
llmgw ctl usage export --from 2026-09-01 --to 2026-10-01 --by project,model
|
||||
llmgw ctl kb list # knowledge-base collections, grants, defaults
|
||||
```
|
||||
|
||||
Bare `llmgw` opens the console, which does all of this from one screen.
|
||||
|
||||
+735
-231
File diff suppressed because it is too large.
Load diff
@@ -28,6 +28,8 @@ message AgentMessage {
|
||||
FabricTestReply fabric_test = 6;
|
||||
UpgradeReply upgrade = 7;
|
||||
KVEvents kv_events = 8;
|
||||
TrainStatus train_status = 9;
|
||||
TrainArtifact train_artifact = 10;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +75,9 @@ message ControlMessage {
|
||||
ControlPlanes control_planes = 11;
|
||||
Firewall firewall = 13;
|
||||
RestartAgent restart_agent = 14;
|
||||
AgentSettings agent_settings = 15;
|
||||
TrainStart train_start = 16;
|
||||
TrainCancel train_cancel = 17;
|
||||
}
|
||||
// trace is the W3C trace context (traceparent, and tracestate when there
|
||||
// is one) of the control plane's span that sent this command, so what the
|
||||
@@ -103,6 +108,17 @@ message Firewall {
|
||||
int32 engine_ports_to = 4;
|
||||
}
|
||||
|
||||
// AgentSettings is what the cluster's settings say of this node's agent
|
||||
// (agents.default, with agents.nodes.<id> over it), sent when it registers
|
||||
// and whenever they change. The agent keeps it beside agent.yaml, which
|
||||
// holds only what it needs to start; over that file, it is what the agent
|
||||
// runs on. A change from what it runs on has the agent start again on it,
|
||||
// its engines serving throughout.
|
||||
message AgentSettings {
|
||||
// yaml is agent.yaml's keys, as YAML; empty for none.
|
||||
string yaml = 1;
|
||||
}
|
||||
|
||||
// ControlPlanes is every control plane an agent may dial, sent by the leader
|
||||
// when an agent registers and whenever the list changes. The agent keeps it
|
||||
// beside its configuration, so a second control plane added after the node
|
||||
@@ -872,3 +888,55 @@ message LoraAdapter {
|
||||
string name = 1;
|
||||
string source = 2;
|
||||
}
|
||||
|
||||
// TrainStart has a node train a LoRA adapter (BACKLOG F4): on a base model's
|
||||
// weights from its registry, on question/answer pairs, with a Python trainer
|
||||
// the agent carries, in an environment it makes over an installed engine's.
|
||||
// One at a time per node.
|
||||
message TrainStart {
|
||||
string job_id = 1;
|
||||
// base_repo and base_revision name the base model's weights on its
|
||||
// registry (Hugging Face): safetensors, not the GGUF a catalog may serve.
|
||||
string base_repo = 2;
|
||||
string base_revision = 3;
|
||||
// data is the training pairs, NDJSON of {"q", "a"}.
|
||||
bytes data = 4;
|
||||
// params is the trainer's settings as JSON: rank, alpha, epochs, lr,
|
||||
// batch, max_len.
|
||||
string params = 5;
|
||||
// gpu_memory_mib is what the job needs of a card; the agent starts it
|
||||
// only when one has that much free, which lets it run beside an engine.
|
||||
int32 gpu_memory_mib = 6;
|
||||
// gguf asks for the adapter in llama.cpp's format too.
|
||||
bool gguf = 7;
|
||||
}
|
||||
|
||||
// TrainCancel stops a training job.
|
||||
message TrainCancel {
|
||||
string job_id = 1;
|
||||
}
|
||||
|
||||
// TrainStatus is how a training job is going, sent as it changes.
|
||||
message TrainStatus {
|
||||
string job_id = 1;
|
||||
// state is preparing, running, done or failed.
|
||||
string state = 2;
|
||||
int32 step = 3;
|
||||
int32 steps = 4;
|
||||
double loss = 5;
|
||||
// message is what is being done, or why it failed.
|
||||
string message = 6;
|
||||
// peak_mib is the most the job held of the card.
|
||||
int32 peak_mib = 7;
|
||||
}
|
||||
|
||||
// TrainArtifact is a piece of what a training job made, sent before its
|
||||
// final status: a gzipped tar of the PEFT adapter (kind peft), or the GGUF
|
||||
// adapter (kind gguf).
|
||||
message TrainArtifact {
|
||||
string job_id = 1;
|
||||
string kind = 2;
|
||||
int32 seq = 3;
|
||||
bytes data = 4;
|
||||
bool last = 5;
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
// Command llmgw-kb puts a person's documents into an llmgw knowledge base
|
||||
// from their own machine (BACKLOG F2): it reads the files there, cuts them
|
||||
// into chunks, has the gateway embed them with the person's key, and sends
|
||||
// the text and vectors — only for what changed since the last run, which a
|
||||
// ledger on the machine remembers.
|
||||
//
|
||||
// Its own binary, for Windows, macOS and Linux: it carries none of the
|
||||
// server, agent or setup code that llmgw does.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/kb/extract"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/kb/kbsync"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/version"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := root().Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// config is what `login` keeps: the gateway, the key, and for a personal key
|
||||
// the project it acts in.
|
||||
type config struct {
|
||||
Gateway string `json:"gateway"`
|
||||
Key string `json:"key"`
|
||||
Project string `json:"project,omitempty"`
|
||||
CA string `json:"ca,omitempty"`
|
||||
}
|
||||
|
||||
func configPath() (string, error) {
|
||||
dir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "llmgw-kb", "config.json"), nil
|
||||
}
|
||||
|
||||
// loadConfig reads the saved configuration, with the environment over it:
|
||||
// LLMGW_URL, LLMGW_API_KEY, LLMGW_PROJECT.
|
||||
func loadConfig() (config, error) {
|
||||
var c config
|
||||
if p, err := configPath(); err == nil {
|
||||
if b, err := os.ReadFile(p); err == nil {
|
||||
if err := json.Unmarshal(b, &c); err != nil {
|
||||
return c, fmt.Errorf("%s: %w", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
for env, dst := range map[string]*string{"LLMGW_URL": &c.Gateway, "LLMGW_API_KEY": &c.Key, "LLMGW_PROJECT": &c.Project} {
|
||||
if v := strings.TrimSpace(os.Getenv(env)); v != "" {
|
||||
*dst = v
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func client(flags *globalFlags) (*kbsync.Client, error) {
|
||||
c, err := loadConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if flags.gateway != "" {
|
||||
c.Gateway = flags.gateway
|
||||
}
|
||||
if flags.project != "" {
|
||||
c.Project = flags.project
|
||||
}
|
||||
if flags.ca != "" {
|
||||
c.CA = flags.ca
|
||||
}
|
||||
if c.Gateway == "" || c.Key == "" {
|
||||
return nil, errors.New("no gateway or key: run `llmgw-kb login`, or set LLMGW_URL and LLMGW_API_KEY")
|
||||
}
|
||||
h, err := kbsync.NewHTTP(c.CA)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &kbsync.Client{Base: c.Gateway, Key: c.Key, Project: c.Project, HTTP: h}, nil
|
||||
}
|
||||
|
||||
type globalFlags struct {
|
||||
gateway, project, ca, ledger string
|
||||
}
|
||||
|
||||
func (g *globalFlags) openLedger(ctx context.Context) (*kbsync.Ledger, error) {
|
||||
p := g.ledger
|
||||
if p == "" {
|
||||
var err error
|
||||
if p, err = kbsync.DefaultLedgerPath(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return kbsync.OpenLedger(ctx, p)
|
||||
}
|
||||
|
||||
func root() *cobra.Command {
|
||||
g := &globalFlags{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "llmgw-kb",
|
||||
Short: "Put your documents into an llmgw knowledge base, from this machine",
|
||||
Long: `llmgw-kb reads documents on this machine, cuts them into chunks, has the
|
||||
gateway embed them with your key, and sends the text and vectors to a
|
||||
knowledge base. Files are never uploaded. Run it as often as you like: a
|
||||
ledger on this machine remembers what was sent, and only what changed is
|
||||
read and sent again.`,
|
||||
Version: version.Version,
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
}
|
||||
cmd.PersistentFlags().StringVar(&g.gateway, "gateway", "", "the gateway's address (default: from login, or LLMGW_URL)")
|
||||
cmd.PersistentFlags().StringVar(&g.project, "project", "", "the project a personal key acts in (default: from login, or LLMGW_PROJECT)")
|
||||
cmd.PersistentFlags().StringVar(&g.ca, "ca", "", "a PEM file of certificates to trust besides the system's")
|
||||
cmd.PersistentFlags().StringVar(&g.ledger, "ledger", "", "the ledger file (default: in your configuration directory)")
|
||||
cmd.AddCommand(loginCmd(g), collectionsCmd(g), syncCmd(g), statusCmd(g), historyCmd(g), formatsCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func loginCmd(g *globalFlags) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "login",
|
||||
Short: "Save the gateway's address and your key",
|
||||
Long: `Asks for the gateway's address and your API key, checks them, and saves them
|
||||
in your configuration directory, readable by you only. The key is typed
|
||||
without being shown, so it does not end up in your shell's history.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
c, _ := loadConfig()
|
||||
in := bufio.NewReader(os.Stdin)
|
||||
ask := func(q, def string) string {
|
||||
if def != "" {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s [%s]: ", q, def)
|
||||
} else {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s: ", q)
|
||||
}
|
||||
s, _ := in.ReadString('\n')
|
||||
if s = strings.TrimSpace(s); s == "" {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
c.Gateway = strings.TrimRight(ask("Gateway address (https://…)", or(g.gateway, c.Gateway)), "/")
|
||||
fmt.Fprint(cmd.OutOrStdout(), "API key: ")
|
||||
var key string
|
||||
if term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
b, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Fprintln(cmd.OutOrStdout())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key = strings.TrimSpace(string(b))
|
||||
} else {
|
||||
key, _ = in.ReadString('\n')
|
||||
key = strings.TrimSpace(key)
|
||||
}
|
||||
if key != "" {
|
||||
c.Key = key
|
||||
}
|
||||
c.Project = ask("Project, for a personal key (empty for a project's key)", or(g.project, c.Project))
|
||||
if g.ca != "" {
|
||||
c.CA = g.ca
|
||||
}
|
||||
h, err := kbsync.NewHTTP(c.CA)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cl := &kbsync.Client{Base: c.Gateway, Key: c.Key, Project: c.Project, HTTP: h, Wait: time.Second}
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
cs, err := cl.Collections(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the gateway refused: %w", err)
|
||||
}
|
||||
p, err := configPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
b, _ := json.MarshalIndent(c, "", " ")
|
||||
if err := os.WriteFile(p, b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Saved in %s. This key reaches %d collection(s); `llmgw-kb collections` lists them.\n", p, len(cs))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func or(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func collectionsCmd(g *globalFlags) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "collections",
|
||||
Short: "List the collections your key may read, and which it may add to",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
c, err := client(g)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cs, err := c.Collections(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(cs) == 0 {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "No collections. An administrator creates them and grants access (llmgw ctl kb).")
|
||||
return nil
|
||||
}
|
||||
tw := tabwriter.NewWriter(cmd.OutOrStdout(), 2, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "NAME\tACCESS\tOWNER\tEMBEDDED BY")
|
||||
for _, x := range cs {
|
||||
access := "read"
|
||||
if x.Writable {
|
||||
access = "read, write"
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s (%d)\n", x.Name, access, or(x.OwnerName, x.Owner), x.EmbedModel, x.Dims)
|
||||
}
|
||||
return tw.Flush()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func syncCmd(g *globalFlags) *cobra.Command {
|
||||
var o kbsync.Options
|
||||
var verbose bool
|
||||
var ocr string
|
||||
var maxMiB int64
|
||||
cmd := &cobra.Command{
|
||||
Use: "sync COLLECTION PATH...",
|
||||
Short: "Send a collection what changed in these folders and files",
|
||||
Long: `Reads the files under each PATH that the collection does not already have as
|
||||
they are, and sends their text and vectors. Run it again whenever you like;
|
||||
unchanged files are not even read.
|
||||
|
||||
Documents are named by their path under PATH, with forward slashes; with
|
||||
several PATHs, each folder's own name comes first.
|
||||
|
||||
--dry-run reads and cuts everything, sends nothing, and says what would
|
||||
leave this machine
|
||||
--delete removes documents this machine sent whose files are gone
|
||||
(only under these PATHs; nothing anybody else sent)
|
||||
--retry reads again files skipped before (after installing LibreOffice
|
||||
or Tesseract, say)
|
||||
|
||||
Without --include, every file of a known format is read (llmgw-kb formats);
|
||||
hidden files, version control folders and Office lock files are not.`,
|
||||
Args: cobra.MinimumNArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client(g)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt)
|
||||
defer stop()
|
||||
l, err := g.openLedger(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
o.Collection, o.Roots = args[0], args[1:]
|
||||
o.Extract = extract.Options{Helpers: extract.FindHelpers(), OCRLanguages: ocr, MaxBytes: maxMiB << 20}
|
||||
out := cmd.OutOrStdout()
|
||||
c.Waiting = func(why string, d time.Duration) {
|
||||
fmt.Fprintf(out, " .. waiting %s: %s\n", d, why)
|
||||
}
|
||||
start := time.Now()
|
||||
rep, err := kbsync.Sync(ctx, c, l, o, func(e kbsync.Event) {
|
||||
switch e.Kind {
|
||||
case "unchanged":
|
||||
if !verbose {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(out, " = %s\n", e.Name)
|
||||
case "sent", "would-send":
|
||||
fmt.Fprintf(out, " %-8s %s (%d chunks, %s)\n", label(e.Kind), e.Name, e.Chunks, size(e.Bytes))
|
||||
default:
|
||||
line := fmt.Sprintf(" %-8s %s", label(e.Kind), e.Name)
|
||||
if e.Detail != "" {
|
||||
line += ": " + e.Detail
|
||||
}
|
||||
fmt.Fprintln(out, line)
|
||||
}
|
||||
})
|
||||
if rep.Collection.Name != "" {
|
||||
verb := "sent"
|
||||
if o.DryRun {
|
||||
verb = "would send"
|
||||
}
|
||||
fmt.Fprintf(out, "\n%s: %d files; %s %d (%d chunks, %s), %d unchanged, %d skipped, %d failed, %d deleted, in %s\n",
|
||||
rep.Collection.Name, rep.Files, verb, rep.Sent, rep.Chunks, size(rep.Bytes), rep.Unchanged,
|
||||
rep.Skipped, rep.Failed, rep.Deleted, time.Since(start).Round(time.Second))
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rep.Failed > 0 {
|
||||
return fmt.Errorf("%d document(s) failed; run again to retry them", rep.Failed)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
f := cmd.Flags()
|
||||
f.StringSliceVar(&o.Include, "include", nil, "read only files matching these globs (\"*.pdf,docs/**/*.md\")")
|
||||
f.StringSliceVar(&o.Exclude, "exclude", nil, "never read files matching these globs")
|
||||
f.BoolVar(&o.Hidden, "hidden", false, "read hidden files and folders too")
|
||||
f.BoolVar(&o.DryRun, "dry-run", false, "send nothing; say what would be sent")
|
||||
f.BoolVar(&o.Delete, "delete", false, "remove documents this machine sent whose files are gone")
|
||||
f.BoolVar(&o.Retry, "retry", false, "read again files skipped before")
|
||||
f.IntVar(&o.Workers, "workers", 4, "files read and sent at once")
|
||||
f.StringVar(&ocr, "ocr-languages", "tur+eng", "Tesseract's languages for images and scanned pages")
|
||||
f.Int64Var(&maxMiB, "max-size", 256, "skip files larger than this many MiB")
|
||||
f.BoolVarP(&verbose, "verbose", "v", false, "list unchanged files too")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func label(kind string) string {
|
||||
switch kind {
|
||||
case "would-send":
|
||||
return "send"
|
||||
case "would-delete":
|
||||
return "delete"
|
||||
}
|
||||
return kind
|
||||
}
|
||||
|
||||
func size(b int64) string {
|
||||
switch {
|
||||
case b >= 1<<20:
|
||||
return fmt.Sprintf("%.1f MiB", float64(b)/(1<<20))
|
||||
case b >= 1<<10:
|
||||
return fmt.Sprintf("%.0f KiB", float64(b)/(1<<10))
|
||||
}
|
||||
return fmt.Sprintf("%d B", b)
|
||||
}
|
||||
|
||||
func statusCmd(g *globalFlags) *cobra.Command {
|
||||
var all bool
|
||||
return &cobra.Command{
|
||||
Use: "status COLLECTION",
|
||||
Short: "Show what this machine has sent to a collection, and what it skipped",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client(g)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m, err := c.Manifest(cmd.Context(), args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := g.openLedger(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
es, err := l.Entries(cmd.Context(), strings.TrimRight(c.Base, "/"), m.Collection.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
counts := map[kbsync.State]int{}
|
||||
tw := tabwriter.NewWriter(cmd.OutOrStdout(), 2, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "STATE\tDOCUMENT\tCHUNKS\tWHEN\tWHY")
|
||||
for _, e := range es {
|
||||
counts[e.State]++
|
||||
if e.State == kbsync.Sent && !all {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%d\t%s\t%s\n", e.State, e.Name, e.Chunks, e.At.Format("2006-01-02 15:04"), e.Reason)
|
||||
}
|
||||
_ = tw.Flush()
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "\n%s holds %d documents; from this machine %d sent, %d skipped, %d failed.",
|
||||
m.Collection.Name, len(m.Documents), counts[kbsync.Sent], counts[kbsync.Skipped], counts[kbsync.Failed])
|
||||
if !all {
|
||||
fmt.Fprint(cmd.OutOrStdout(), " (--all lists the sent ones too)")
|
||||
}
|
||||
fmt.Fprintln(cmd.OutOrStdout())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func historyCmd(g *globalFlags) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "history",
|
||||
Short: "List the latest syncs from this machine",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
l, err := g.openLedger(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer l.Close()
|
||||
runs, err := l.Runs(cmd.Context(), 20)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tw := tabwriter.NewWriter(cmd.OutOrStdout(), 2, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "STARTED\tTOOK\tCOLLECTION\tSENT\tUNCHANGED\tSKIPPED\tFAILED\tDELETED")
|
||||
for _, r := range runs {
|
||||
took := "unfinished"
|
||||
if !r.Finished.IsZero() {
|
||||
took = r.Finished.Sub(r.Started).Round(time.Second).String()
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%d\t%d\t%d\t%d\t%d\n", r.Started.Format("2006-01-02 15:04"), took,
|
||||
r.Collection, r.Sent, r.Unchanged, r.Skipped, r.Failed, r.Delete)
|
||||
}
|
||||
return tw.Flush()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func formatsCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "formats",
|
||||
Short: "List the formats read, and the helpers found on this machine",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
builtIn, helper := extract.Supported()
|
||||
out := cmd.OutOrStdout()
|
||||
fmt.Fprintf(out, "Read here:\n%s\n\n", wrap(builtIn))
|
||||
fmt.Fprintf(out, "Read with a helper program:\n%s\n\n", wrap(helper))
|
||||
fmt.Fprintln(out, "Helpers:")
|
||||
for _, l := range extract.FindHelpers().Describe() {
|
||||
fmt.Fprintln(out, " "+l)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func wrap(words []string) string {
|
||||
var b strings.Builder
|
||||
line := " "
|
||||
for _, w := range words {
|
||||
if len(line)+len(w) > 78 {
|
||||
b.WriteString(strings.TrimRight(line, " ") + "\n")
|
||||
line = " "
|
||||
}
|
||||
line += w + " "
|
||||
}
|
||||
b.WriteString(strings.TrimRight(line, " "))
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The client goes onto employees' laptops: none of the server, agent, store
|
||||
// or setup code may come with it, and it must build where llmgw does not.
|
||||
func TestTheClientCarriesNoServerCode(t *testing.T) {
|
||||
out, err := exec.Command("go", "list", "-deps", ".").Output()
|
||||
if err != nil {
|
||||
t.Skipf("go list: %v", err)
|
||||
}
|
||||
for _, dep := range strings.Fields(string(out)) {
|
||||
for _, banned := range []string{"/internal/store", "/internal/router", "/internal/agent", "/internal/control",
|
||||
"/internal/cli", "/internal/guard", "github.com/jackc/pgx"} {
|
||||
if strings.Contains(dep, banned) {
|
||||
t.Errorf("llmgw-kb depends on %s", dep)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ func (d Doc) Source() string {
|
||||
// than with either reader, because the console's manual and the gateway's
|
||||
// docs portal both list them, and two lists drift.
|
||||
var docOrder = []string{"console", "quick-install", "install", "node-onboarding", "onboarding",
|
||||
"clients", "guardrails", "choosing-models", "fabrics", "facility-aware-placement", "data",
|
||||
"clients", "guardrails", "knowledge", "choosing-models", "fabrics", "facility-aware-placement", "data",
|
||||
"reference", "development"}
|
||||
|
||||
// Manual is every document, in reading order: those docOrder names, then any
|
||||
|
||||
+16
-7
@@ -8,8 +8,9 @@ description is.
|
||||
anything they do can be done with `curl`, a generated client or the
|
||||
[Terraform provider](../terraform-provider-llmgw/README.md). It adds and
|
||||
removes models, scales them, cordons and upgrades nodes, sets capacity
|
||||
quotas, attaches guardrail policies, and reads the fleet, usage and the audit
|
||||
log.
|
||||
quotas, attaches guardrail policies, adds organizations, teams and projects,
|
||||
issues and revokes API keys, puts people in the tree, and reads the fleet,
|
||||
usage and the audit log.
|
||||
|
||||
## Reaching it
|
||||
|
||||
@@ -99,6 +100,14 @@ curl -s "${H[@]}" -X POST $API/models/qwen-small/scale -d '{"min": 0, "max": 2,
|
||||
# A team's capacity quota.
|
||||
curl -s "${H[@]}" -X PUT $API/quotas -d '{"scope": "team:Acme/ml", "limit": {"gpus": 8}}'
|
||||
|
||||
# An organization, a team in it and a project in that; then a key for the
|
||||
# project. The answer holds the key once: only its hash is kept.
|
||||
curl -s "${H[@]}" -X POST $API/orgs -d '{"name": "Acme"}'
|
||||
curl -s "${H[@]}" -X POST $API/teams -d '{"name": "ml", "in": "Acme"}'
|
||||
curl -s "${H[@]}" -X POST $API/projects -d '{"name": "search", "in": "Acme/ml"}'
|
||||
curl -s "${H[@]}" -X POST $API/keys -d '{"name": "ci", "project": "Acme/ml/search", "expires_in": "720h"}'
|
||||
curl -s "${H[@]}" -X POST $API/users/ayse@example.com/memberships -d '{"target": "team:Acme/ml"}'
|
||||
|
||||
# Remove the model; a DELETE carries the content type too.
|
||||
curl -s "${H[@]}" -X DELETE $API/models/qwen-small
|
||||
```
|
||||
@@ -149,11 +158,11 @@ go generate ./internal/docgen
|
||||
|
||||
## What it does not cover
|
||||
|
||||
- **Organizations, teams, projects, people and API keys.** These live in the
|
||||
store and are managed with `llmgw org`, `llmgw users` and `llmgw keys` on the
|
||||
control plane, or in the web console's `/admin` pages on the client port
|
||||
behind a session. The admin API reads the tree only where a quota or a
|
||||
policy names a part of it.
|
||||
- **Making people's accounts.** People sign up, are invited, or come from
|
||||
the identity provider (OIDC, SCIM); the API lists them and puts them in
|
||||
the tree, and `llmgw users` approves, disables and deletes them. Key
|
||||
lifetime limits and spending limits are `llmgw keys ttl` and `llmgw keys
|
||||
limits`.
|
||||
- **The client-facing API** (`/v1/chat/completions` and the rest), which is
|
||||
OpenAI's and Anthropic's, on `listen`, with API keys.
|
||||
- **The rest of the admin listener**: `/metrics`, `/health`, the dashboard
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -164,6 +164,15 @@ which): requests, tokens, errors and spend.
|
||||
team or project is guaranteed and may at most hold, and what it holds now.
|
||||
**A** adds one; on a row, change or remove it.
|
||||
|
||||
**Knowledge** lists the knowledge-base collections: each one's owner, the
|
||||
model that embeds it, how many documents and chunks it holds and who else it
|
||||
is granted to. **A** makes one. On a row: its **Documents**, **Grant…** a
|
||||
project, team, organization, person or key read or write access, **Revoke**
|
||||
a grant, **Answer from it by default…** for a part of the tree, **Try a
|
||||
search…** as an administrator, or **Delete…** it. People
|
||||
fill collections from their own machines with `llmgw-kb`
|
||||
([knowledge.md](knowledge.md)). Off on a SQLite store, and says why.
|
||||
|
||||
**Mirror** is what the model mirror holds, one row for each version of each
|
||||
model — a file, or the set of files a model of several is: shards, a vision
|
||||
projector, a config and tokenizer — with how far its download has got and
|
||||
|
||||
+73
-12
@@ -201,6 +201,16 @@ this by running the same command again, on the control plane that holds the
|
||||
authority: etcd is rebuilt on TLS with Patroni in maintenance mode, so
|
||||
PostgreSQL serves throughout and no failover happens.
|
||||
|
||||
Each member's PostgreSQL is sized to the machine's memory, read each time
|
||||
the command runs: a quarter of it as cache (`shared_buffers`), three quarters
|
||||
counted on for the planner, and room to sort and build indexes, all capped. A
|
||||
member that also runs a control plane or an agent gets less: about 15% as
|
||||
cache, so the gateway is not pushed into swap. A knowledge base's vector
|
||||
index is searched well only from memory, so give a store machine more RAM and
|
||||
run the command again. The new cache takes a PostgreSQL restart, which Patroni
|
||||
does one member at a time, the leader last. The leader's restart costs a few
|
||||
seconds without writes, and there is no failover.
|
||||
|
||||
Every step looks before it acts. Running it again finishes an install that
|
||||
stopped halfway and changes nothing on one that did not; it never overwrites
|
||||
`server.yaml`, the authority, or an existing account's password.
|
||||
@@ -258,8 +268,14 @@ llmgw ctl config history # who changed what, when, and why
|
||||
llmgw ctl config rollback 4 # back to version 4, as a new version
|
||||
```
|
||||
|
||||
or on the console's **Settings** screen. A change is checked before it is
|
||||
kept — one that would not load is refused — and recorded in the audit log.
|
||||
or on the console's **Settings** screen, or in a browser on the portal's
|
||||
**Settings** page (`/admin/settings`, for administrators only: the document
|
||||
can hold webhook secrets). The page edits the same document, lists its
|
||||
history, and puts an earlier version back. A change is checked before it is
|
||||
kept, so one that would not load is refused. A change made from a version
|
||||
someone else has replaced since is refused too, rather than overwriting
|
||||
theirs. Every change is recorded in the audit log, with the document's size
|
||||
but not its contents.
|
||||
Guardrails change live; anything else has every control plane start again on
|
||||
the new settings, the standbys first and the leader half a minute later, so
|
||||
one of them is always serving. Engines keep running through it.
|
||||
@@ -269,6 +285,33 @@ cluster keys — with a `baseline` guard policy, if the file had none — and
|
||||
from then on those keys in any server.yaml are not read (the server says so
|
||||
when it starts).
|
||||
|
||||
**The nodes' agents** are set the same way, under `agents`. `agents.default`
|
||||
is for every node, and `agents.nodes.<node-id>` is for one node, laid over
|
||||
the default:
|
||||
|
||||
```
|
||||
llmgw ctl config set agents.default.heartbeat_interval 5s
|
||||
llmgw ctl config set agents.nodes.cuda01.labels.gpu rtx6000
|
||||
llmgw ctl config edit # fabrics, engines, supervisor, discovery…
|
||||
```
|
||||
|
||||
Each node's `agent.yaml` then needs only what its agent must know before it
|
||||
can be told anything: `node_id`, `server` or `servers`, `hostname`,
|
||||
`mgmt_addr` and `tls`. Those keys are refused under `agents`.
|
||||
|
||||
- **Precedence.** Anything else in the file is still read, with the
|
||||
cluster's settings over it.
|
||||
- **Applying a change.** The control plane sends each node its settings, a
|
||||
second apart. An agent whose settings changed keeps them beside its
|
||||
`agent.yaml` (in `/var/lib/llmgw-agent/agent-settings.yaml`) and starts
|
||||
again on them, its engines serving throughout.
|
||||
- **Bad settings.** Settings that would not load are refused when they are
|
||||
saved. If they somehow do not load beside one node's own file, that agent
|
||||
keeps running on what it has and logs why.
|
||||
- **Editing `agent.yaml` itself.** The agent notices the change within ten
|
||||
seconds and starts again on it. If the edited file does not load, the
|
||||
agent reports it and keeps running on what it had.
|
||||
|
||||
**This machine's own keys** stay in `/etc/llmgw/server.yaml`: `listen`,
|
||||
`admin_listen`, `metrics_listen`, `agent_listen`, `agent_tls`, `auth.store`,
|
||||
`secrets`, `tracing`, `monitoring`, `mirror.api` and `mirror.tls`, the backup
|
||||
@@ -1703,9 +1746,11 @@ the file again.
|
||||
|
||||
On a machine that also runs the control plane, PostgreSQL's defaults assume it
|
||||
owns the box. A gateway swapped out to give the database cache is a worse
|
||||
outcome than a smaller cache, so cap it: `shared_buffers = 96MB` and
|
||||
`max_connections = 40` are enough for a store whose working set is measured in
|
||||
megabytes.
|
||||
outcome than a smaller cache, so size it to the machine. `server init` does this
|
||||
for a PostgreSQL it installs: `shared_buffers` about 15% of memory (at least
|
||||
96MB), `effective_cache_size` half, `work_mem` and `maintenance_work_mem`
|
||||
scaled to it, and `max_connections = 40`. A store with a large knowledge base
|
||||
wants a machine of its own, where its vector index fits in memory.
|
||||
|
||||
## Tagging requests
|
||||
|
||||
@@ -2151,13 +2196,29 @@ llmgw ctl config set firewall.enabled false # every machine's table taken out
|
||||
llmgw ctl config set firewall.enabled true # and put back as it was
|
||||
```
|
||||
|
||||
It is in force within seconds: each node's agent takes its table out or puts
|
||||
it back, and each control plane's `llmgw-firewall-sync.path` (installed by
|
||||
`server init` and `server add`; `sudo llmgw firewall sync --install` on one
|
||||
set up before) does the same there. The rules files stay, so turning it back
|
||||
on restores them. Nodes added while it is off get none. A separate mirror
|
||||
machine and the store's members keep theirs for now. `firewall_networks`
|
||||
changes reach the nodes the same way.
|
||||
It is in force on every machine within a minute, each in its own way:
|
||||
|
||||
- **nodes**: the agent takes its table out or puts it back;
|
||||
- **control planes**: `llmgw-firewall-sync.path`, installed by `server
|
||||
init` and `server add` (`sudo llmgw firewall sync --install` on one set up
|
||||
before);
|
||||
- **a separate mirror machine**: the leading control plane tells the mirror,
|
||||
which writes it to `.firewall.json` in its directory, and the same path
|
||||
unit applies it. `mirror init` installs it; on a mirror set up before, run
|
||||
`sudo llmgw mirror init` again with the same flags;
|
||||
- **store members that are nothing else**: they run no llmgw, so
|
||||
`llmgw-firewall-follow.timer` reads what the leading control plane keeps in
|
||||
the store, from the member's own copy, once a minute. `server init
|
||||
--patroni` installs it; run it again on a cluster set up before.
|
||||
|
||||
The rules files stay, so turning it back on restores them. Nodes added while
|
||||
it is off get none. Networks added to `firewall_networks` reach every machine
|
||||
the same way. One taken out of it stays let in until it is taken out of
|
||||
`/etc/llmgw/firewall.nft` by hand: the rules are only ever added to.
|
||||
|
||||
The leading control plane also tells the mirror machine where every node and
|
||||
control plane is, so a node on a network the mirror had not let in can fetch
|
||||
its weights without a `firewall_networks` entry.
|
||||
|
||||
Only those ports are touched. The table's policy is to accept, so whatever
|
||||
else the machine serves — and whatever firewall it already has — is left as
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
# Knowledge bases
|
||||
|
||||
A knowledge base is a collection of a company's documents that the cluster
|
||||
can answer from. People put their documents into it from their own machines
|
||||
with `llmgw-kb`. The tool reads the files on that machine, cuts them into
|
||||
passages ("chunks"), has the gateway embed each passage with the person's own
|
||||
key, and sends the text and the vectors. The files themselves never leave
|
||||
the machine.
|
||||
|
||||
## Why retrieval, and not training
|
||||
|
||||
Retrieval is the tool for *knowledge*: policies, procedures, contracts. The
|
||||
answer can name its source, a changed document is sent again in minutes, and
|
||||
a passage only ever reaches people allowed to read its collection.
|
||||
|
||||
Training a model on the documents (LoRA) changes how it *writes*, not what it
|
||||
reliably *knows*. It has no access control: whatever goes into the weights
|
||||
is available to everyone who may use them.
|
||||
|
||||
## What it needs
|
||||
|
||||
- **The PostgreSQL store with pgvector.** On a SQLite store knowledge bases
|
||||
are off, and `llmgw ctl kb list` and the console say why.
|
||||
- `llmgw server init` and the high-availability database setup install
|
||||
pgvector and enable it.
|
||||
- A cluster set up before this build needs it by hand, once:
|
||||
1. Install the package on every database machine:
|
||||
`sudo apt-get install postgresql-16-pgvector` (use the server's
|
||||
version number).
|
||||
2. On the leader, enable it in llmgw's database:
|
||||
`sudo -u postgres psql -d llmgw -c 'CREATE EXTENSION vector'`.
|
||||
3. Restart the control planes: `llmgw restart`.
|
||||
- **An embedding model in the catalog**, with `capabilities.embeddings`.
|
||||
Two good choices handle Turkish and run on CPUs: `BAAI/bge-m3` (1024
|
||||
dimensions) and `Qwen/Qwen3-Embedding-0.6B`.
|
||||
|
||||
Add it as any model is added (**Serve a model** in the console, or the
|
||||
catalog), with its entry saying that it embeds:
|
||||
|
||||
```yaml
|
||||
capabilities:
|
||||
embeddings: true
|
||||
```
|
||||
|
||||
A collection stays with the model it was made with: vectors from two
|
||||
models cannot be compared.
|
||||
|
||||
With pgvector 0.7 or later a collection's index is kept at half
|
||||
precision (`halfvec`), which halves its memory and takes models of up to
|
||||
4000 dimensions; with an older pgvector the limit is 2000. A collection
|
||||
keeps the kind it was made with.
|
||||
|
||||
## For administrators
|
||||
|
||||
### Make a collection
|
||||
|
||||
A collection belongs to one part of the tenancy tree: an organization, a
|
||||
team, a project or a person. Whoever is in the owner, at any level below it,
|
||||
may read it and add to it.
|
||||
|
||||
```sh
|
||||
llmgw ctl kb create team:Acme/hr policies bge-m3
|
||||
llmgw ctl kb create user:USER-ID notes bge-m3 --org Acme
|
||||
```
|
||||
|
||||
The model's dimension is asked of the model itself, so the model has to be
|
||||
able to serve when the collection is made. `--dims 1024` gives it instead.
|
||||
|
||||
In the console: **Knowledge**, then **A**.
|
||||
|
||||
### Who may read and add
|
||||
|
||||
The owner, and everything below it, may read and add. A **grant** gives the
|
||||
same to another part of the same organization, to a person, or to one key:
|
||||
|
||||
```sh
|
||||
llmgw ctl kb grant Acme/policies project:Acme/sales/crm # read
|
||||
llmgw ctl kb grant Acme/policies team:Acme/legal --write # read and add
|
||||
llmgw ctl kb revoke Acme/policies project:Acme/sales/crm
|
||||
```
|
||||
|
||||
How access works:
|
||||
|
||||
- **Grants add up down the tree.** A grant to a team reaches its projects
|
||||
and their keys.
|
||||
- **Nothing crosses organizations.** A grant to another organization's
|
||||
team, project, key or person is refused.
|
||||
- **A person's own key** (a key with no project) reads nothing by itself.
|
||||
The person names the project they act in (`llmgw-kb login` asks for it,
|
||||
or `--project`) and must be a member of it. Joining a second team
|
||||
therefore does not quietly widen what every key of theirs can read.
|
||||
- **No access looks like no collection.** A collection a key may not read
|
||||
is answered exactly as one that does not exist, so even its name does not
|
||||
leak.
|
||||
|
||||
### Answer from collections by default
|
||||
|
||||
Clients that cannot add a header still get answers from the documents when
|
||||
their part of the tree has a default:
|
||||
|
||||
```sh
|
||||
llmgw ctl kb default team:Acme/hr Acme/policies Acme/forms
|
||||
llmgw ctl kb default key:KEY-ID Acme/policies
|
||||
llmgw ctl kb default team:Acme/hr --clear
|
||||
```
|
||||
|
||||
- **Which default applies.** The narrowest one: a key's over its project's,
|
||||
a project's over its team's, a team's over its organization's.
|
||||
- **A request can override it.** A request that names collections uses
|
||||
those instead, and `X-Llmgw-Knowledge: none` turns the default off for
|
||||
that request.
|
||||
- **A default never refuses a request.** A collection the caller may not
|
||||
read is left out for them. If the search fails (the embedding model is
|
||||
down, say), the request is answered without passages and the failure is
|
||||
logged.
|
||||
- **Changes take effect within 30 seconds.**
|
||||
|
||||
In the console: **Knowledge**, Enter on a collection, then **Answer from it
|
||||
by default…**.
|
||||
|
||||
### Look after it
|
||||
|
||||
```sh
|
||||
llmgw ctl kb list # collections, owners, documents, grants
|
||||
llmgw ctl kb docs Acme/policies # its documents, who added each and when
|
||||
llmgw ctl kb rm Acme/policies old/form.pdf # one document out
|
||||
llmgw ctl kb search Acme/policies how many days of annual leave
|
||||
llmgw ctl kb delete Acme/policies # the collection, its documents and grants
|
||||
```
|
||||
|
||||
Rules that apply:
|
||||
|
||||
- A project, team or organization that owns a collection is not deleted
|
||||
until its collections are. This is the same rule as for one with members.
|
||||
- A person's own collections go with their account (`users delete`,
|
||||
`users forget`).
|
||||
- Every document added or removed is in the audit log (`llmgw ctl audit`),
|
||||
by name. The text is never recorded.
|
||||
|
||||
## For everyone: `llmgw-kb`
|
||||
|
||||
### Get it
|
||||
|
||||
Download `llmgw-kb` for your system from the release page, next to `llmgw`:
|
||||
|
||||
| System | File |
|
||||
|---|---|
|
||||
| Windows (Intel/AMD) | `llmgw-kb-VERSION-windows-amd64.exe` |
|
||||
| Windows (ARM) | `llmgw-kb-VERSION-windows-arm64.exe` |
|
||||
| macOS (Apple silicon) | `llmgw-kb-VERSION-darwin-arm64` |
|
||||
| macOS (Intel) | `llmgw-kb-VERSION-darwin-amd64` |
|
||||
| Linux | `llmgw-kb-VERSION-linux-amd64` / `-arm64` |
|
||||
|
||||
Rename it to `llmgw-kb` (`llmgw-kb.exe` on Windows) and put it somewhere on
|
||||
your PATH. You do not need to install anything else.
|
||||
|
||||
On macOS, a downloaded program that is not signed is stopped the first time.
|
||||
Either right-click it and choose Open, or run
|
||||
`xattr -d com.apple.quarantine llmgw-kb`.
|
||||
|
||||
### Log in once
|
||||
|
||||
```sh
|
||||
llmgw-kb login
|
||||
```
|
||||
|
||||
It asks for three things, checks them, and saves them where only you can
|
||||
read them:
|
||||
|
||||
- **The gateway's address.**
|
||||
- **Your API key.** It is typed without being shown.
|
||||
- **A project.** Needed only for a personal key.
|
||||
|
||||
Instead of logging in you can set `LLMGW_URL`, `LLMGW_API_KEY` and
|
||||
`LLMGW_PROJECT`.
|
||||
|
||||
```sh
|
||||
llmgw-kb collections # what your key may read, and which you may add to
|
||||
```
|
||||
|
||||
### Send your documents
|
||||
|
||||
```sh
|
||||
llmgw-kb sync policies ~/Documents/HR
|
||||
llmgw-kb sync policies "C:\Users\ayse\Documents\İK" --include "*.pdf,*.docx"
|
||||
llmgw-kb sync policies ./handbook ./forms --exclude "drafts/**"
|
||||
```
|
||||
|
||||
Run it as often as you like. The first run sends everything. Later runs
|
||||
send only what changed, and do not even read files whose size and time have
|
||||
not changed.
|
||||
|
||||
| Option | What it does |
|
||||
|---|---|
|
||||
| `--dry-run` | Reads and cuts everything, sends nothing, and lists what would leave the machine and how much. |
|
||||
| `--delete` | Also removes documents whose files are gone. Only those this machine sent, from these folders; never anything someone else sent. |
|
||||
| `--retry` | Reads again files skipped before, for example after installing LibreOffice. |
|
||||
| `--include`, `--exclude` | Glob patterns. `*.pdf` matches a file name; `reports/**/*.xlsx` matches a path. |
|
||||
| `--hidden` | Reads hidden files and folders too (left out by default). |
|
||||
| `-v` | Lists unchanged files too. |
|
||||
|
||||
Documents are named by their path under the folder, with forward slashes,
|
||||
on every system. With several folders, each folder's own name comes first.
|
||||
|
||||
```sh
|
||||
llmgw-kb status policies # what this machine sent and skipped, and why
|
||||
llmgw-kb history # the latest runs
|
||||
llmgw-kb formats # what can be read here
|
||||
```
|
||||
|
||||
### What it reads
|
||||
|
||||
| Read here | Formats |
|
||||
|---|---|
|
||||
| Documents | PDF (text), Word (`.docx`), Excel (`.xlsx`), PowerPoint (`.pptx`, with speaker notes), OpenDocument (`.odt`, `.ods`, `.odp`), RTF, EPUB |
|
||||
| Web and mail | HTML, MHTML, e-mail (`.eml`, with its attachments) |
|
||||
| Text | Markdown, plain text, CSV, JSON, YAML, XML, subtitles, notebooks (`.ipynb`), source code |
|
||||
| Archives | ZIP, with every file inside it read in turn |
|
||||
|
||||
| Read with a helper on your machine | Helper |
|
||||
|---|---|
|
||||
| Word, Excel and PowerPoint 97–2003 (`.doc`, `.xls`, `.ppt`), Pages, Numbers, Keynote and others | [LibreOffice](https://www.libreoffice.org) |
|
||||
| Images and scanned PDFs | [Tesseract](https://tesseract-ocr.github.io) (with the Turkish language data), plus Poppler's `pdftoppm` for PDFs |
|
||||
| PDFs the built-in reader cannot parse | Poppler's `pdftotext` |
|
||||
|
||||
`llmgw-kb formats` says which helpers it found. Text in UTF-8, UTF-16, or
|
||||
the Windows Turkish and Western code pages is read correctly.
|
||||
|
||||
A file that cannot be read is skipped with the reason, and the run goes on.
|
||||
Reasons include a locked Office file, a format with no helper, and a
|
||||
scanned page with no OCR.
|
||||
|
||||
### What leaves your machine
|
||||
|
||||
For each new or changed document, two things leave the machine, both over
|
||||
the gateway's TLS connection:
|
||||
|
||||
- **The passages, to be embedded.** They go through `/v1/embeddings` like
|
||||
any request of yours. Your key's limits, budget and guardrails apply.
|
||||
- **The document itself.** Its name, its file's SHA-256 digest and size,
|
||||
and the passages with their vectors.
|
||||
|
||||
The file itself is never sent. `--dry-run` shows exactly what would be.
|
||||
|
||||
### The ledger
|
||||
|
||||
`llmgw-kb` keeps a small SQLite database of what it did with every
|
||||
document: the file it came from, its size, time and digest, and whether it
|
||||
was sent, skipped or failed, with the reason.
|
||||
|
||||
| System | Where it is |
|
||||
|---|---|
|
||||
| Windows | `%AppData%\llmgw-kb\ledger.db` |
|
||||
| macOS | `~/Library/Application Support/llmgw-kb/ledger.db` |
|
||||
| Linux | `~/.config/llmgw-kb/ledger.db` |
|
||||
|
||||
Deleting the ledger is safe: the next run hashes every file once, finds what
|
||||
the collection already has, and sends nothing that is unchanged. `--ledger`
|
||||
uses another file.
|
||||
|
||||
## Asking questions of a collection
|
||||
|
||||
A chat request names the collections to answer from in a header. Any client
|
||||
that can add a header can do it: the OpenAI and Anthropic SDKs, Continue, or
|
||||
curl.
|
||||
|
||||
```sh
|
||||
curl https://llm.example.com/v1/chat/completions \
|
||||
-H "Authorization: Bearer $LLMGW_API_KEY" \
|
||||
-H "X-Llmgw-Knowledge: politikalar" \
|
||||
-d '{"model": "qwen2.5-coder-14b-instruct",
|
||||
"messages": [{"role": "user", "content": "Kaç gün yıllık iznim var?"}]}'
|
||||
```
|
||||
|
||||
```python
|
||||
client.chat.completions.create(
|
||||
model="qwen2.5-coder-14b-instruct",
|
||||
messages=[{"role": "user", "content": "Kaç gün yıllık iznim var?"}],
|
||||
extra_headers={"X-Llmgw-Knowledge": "politikalar"},
|
||||
)
|
||||
```
|
||||
|
||||
What the gateway does with such a request:
|
||||
|
||||
1. **It finds the collections.** It looks up the ones named, by name or ID,
|
||||
among those the key may read. A collection it may not read is answered
|
||||
404, the same as one that does not exist.
|
||||
2. **It embeds the question.** The last user message is embedded by the
|
||||
collection's model, on the caller's own account: their limits, budget and
|
||||
guardrails apply.
|
||||
3. **It searches.** The collections are searched by meaning and by words
|
||||
(below).
|
||||
4. **It adds the passages.** The best passages are numbered and added to the
|
||||
system message, with an instruction to answer from them, cite them as
|
||||
[1], [2], and say so when they do not hold the answer. The system message
|
||||
is created if the request has none. Passages that would not fit the
|
||||
model's context are left out, the weakest first.
|
||||
5. **It judges and sends the request.** The request, passages included, then
|
||||
goes through the guardrails and to the model as usual. A document
|
||||
carrying instructions aimed at its reader is judged like anything else
|
||||
the model reads.
|
||||
|
||||
On the portal's chat page, **Documents** picks a collection the person may
|
||||
read in the chosen project, the default, or none. Under each answer, the
|
||||
sources it was given are listed by number.
|
||||
|
||||
How it is shaped:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `X-Llmgw-Knowledge` | Collections, comma separated; `none` for no passages, whatever the default. |
|
||||
| `X-Llmgw-Knowledge-K` | How many passages, 1 to 20; 5 by default. |
|
||||
| `X-Llmgw-Project` | For a personal key: the project it acts in. |
|
||||
| `x-llmgw-sources` (answer) | The passages given, as `document#chunk`, in their numbered order; empty when nothing matched. |
|
||||
|
||||
Behaviour to know about:
|
||||
|
||||
- **No passages matched.** The model is told that nothing in the documents
|
||||
answers the question, and asked to say so rather than answer from general
|
||||
knowledge.
|
||||
- **Other APIs.** Requests through `/v1/responses` and `/v1/messages`
|
||||
(Codex, Claude Code) work the same way, since they are answered as chat
|
||||
completions.
|
||||
- **Separate prefix caches.** On vLLM replicas, a request given passages
|
||||
carries a `cache_salt` derived from the collections they came from.
|
||||
Requests that could be given the same passages still share cached blocks,
|
||||
and no one else's do: not another organization's, nor another team's of
|
||||
the same one. How fast an answer starts therefore says nothing about what
|
||||
someone else asked.
|
||||
- **No answer cache.** Answers from knowledge bases are never served from
|
||||
the answer cache, because two keys of one project can read different
|
||||
collections.
|
||||
|
||||
### How a passage is chosen
|
||||
|
||||
Each passage scores the following, and the highest scores win:
|
||||
|
||||
- its similarity in meaning to the question (cosine, 0 to 1), plus
|
||||
- up to 0.3 for the share of the question's words it contains.
|
||||
|
||||
Words almost every passage has ("ne", "için", "the", "of") are not counted.
|
||||
The meaning does most of the work: "how much leave do fathers get" finds the
|
||||
paternity page, not the annual-leave page that shares more of its words.
|
||||
The word match helps with what meaning cannot carry, such as a form number,
|
||||
a product code or a name.
|
||||
|
||||
Metrics: `llmgw_kb_retrievals_total{result}` (found, nothing, refused) and
|
||||
`llmgw_kb_retrieval_seconds`.
|
||||
|
||||
## Training an adapter on a collection
|
||||
|
||||
An adapter (LoRA) changes how a model *writes*: the organisation's words,
|
||||
format and tone. It does not reliably teach facts. On the PoC, a small
|
||||
model trained on nine policy documents answered 4 of 9 rephrased questions
|
||||
right and mixed facts up on the rest, while the same questions through the
|
||||
knowledge base got 9 of 9. Use both: the knowledge base for what answers
|
||||
say, an adapter for how they sound.
|
||||
|
||||
```sh
|
||||
# 1. Questions and answers from the collection, written by a model of the
|
||||
# cluster. Read the file through and correct it.
|
||||
llmgw ctl kb dataset Acme/policies --model qwen2.5-coder-14b-instruct --out policies.jsonl
|
||||
|
||||
# 2. Train on a node. --gpu-memory is what the job needs of a card; it
|
||||
# runs beside an engine when the engine leaves that much free.
|
||||
llmgw ctl train start policies-tone --model qwen2.5-0.5b-instruct \
|
||||
--base-repo Qwen/Qwen2.5-0.5B-Instruct --node cuda01 --gpu-memory 2300 --data policies.jsonl
|
||||
llmgw ctl train show JOB --follow
|
||||
|
||||
# 3. Make it the model's adapter, kept on the mirror.
|
||||
llmgw ctl train publish JOB --restart
|
||||
```
|
||||
|
||||
The node trains with its installed vLLM's torch and a small overlay (peft
|
||||
and accelerate), as the engines' user. For a model llama.cpp serves, it also
|
||||
converts the adapter to GGUF with the installed llama.cpp's own code. What
|
||||
it makes is kept by the control plane (`llmgw ctl train download`).
|
||||
|
||||
An adapter is private like its collection:
|
||||
|
||||
- **It has an owner.** An adapter trained on a collection carries some of
|
||||
what the collection says in its weights, so it belongs to a part of the
|
||||
tree.
|
||||
- By default the owner is the collection's owner.
|
||||
- `--owner` names another, which must be able to read every collection
|
||||
the pairs came from.
|
||||
- Only callers in the owner's part of the tree see the adapter. To anyone
|
||||
else it is a model that does not exist, absent from `/v1/models` too.
|
||||
- **It is public only by choice.** `--public` makes an adapter every
|
||||
caller's. It is refused for pairs from a knowledge base, so it is only for
|
||||
pairs written by hand.
|
||||
|
||||
## How it is kept apart
|
||||
|
||||
Passages live in PostgreSQL, partitioned by organization, with an index per
|
||||
collection. Every search and every write names the organization and the
|
||||
collections the caller may use. On top of that, row-level security on the
|
||||
table checks the same thing again, so a query that forgot its filter
|
||||
returns nothing rather than another tenant's passages.
|
||||
@@ -18,6 +18,7 @@ rather than repeated.
|
||||
| `name` | `string` | [Adapter](#adapter) |
|
||||
| `source` | `string` | [Adapter](#adapter) |
|
||||
| `scale` | `float64` | [Adapter](#adapter) |
|
||||
| `owner` | `string` | [Adapter](#adapter) |
|
||||
| `enabled` | `bool` | [Admission](#admission) |
|
||||
| `bands` | `[]Band` | [Admission](#admission) |
|
||||
| `default_band` | `string` | [Admission](#admission) |
|
||||
@@ -26,6 +27,8 @@ rather than repeated.
|
||||
| `queue_capacity_per_band` | `int` | [Admission](#admission) |
|
||||
| `queue_capacity_total` | `int` | [Admission](#admission) |
|
||||
| `interval` | `time.Duration` | [Admission](#admission) |
|
||||
| `default` | `map[string]any` | [AgentSettings](#agentsettings) |
|
||||
| `nodes` | `map[string]map[string]any` | [AgentSettings](#agentsettings) |
|
||||
| `enabled` | `bool` | [Auth](#auth) |
|
||||
| `store` | `string` | [Auth](#auth) |
|
||||
| `cache_ttl` | `time.Duration` | [Auth](#auth) |
|
||||
@@ -294,6 +297,7 @@ rather than repeated.
|
||||
| `mail` | `mail.Config` | [Server](#server) |
|
||||
| `serving` | `ServingSettings` | [Server](#server) |
|
||||
| `firewall` | `FirewallSettings` | [Server](#server) |
|
||||
| `agents` | `AgentSettings` | [Server](#server) |
|
||||
| `firewall_networks` | `[]string` | [Server](#server) |
|
||||
| `backup_recipients` | `[]string` | [Server](#server) |
|
||||
| `backup_copy_to` | `[]string` | [Server](#server) |
|
||||
@@ -423,6 +427,16 @@ weights are.
|
||||
|
||||
Scale is how strongly it is applied; zero takes 1.
|
||||
|
||||
### `owner`
|
||||
|
||||
`string`
|
||||
|
||||
Owner is the part of the tenancy tree the adapter belongs to —
|
||||
org:ID, team:ID, project:ID, user:ID or key:ID — and only callers in
|
||||
it see it: to anyone else it is a model that does not exist. An
|
||||
adapter trained on an organisation's documents carries some of what
|
||||
they say in its weights. Empty is every caller the model allows.
|
||||
|
||||
## Admission
|
||||
|
||||
### `enabled`
|
||||
@@ -469,6 +483,21 @@ Zero takes half of all the bands' capacities together.
|
||||
|
||||
`time.Duration`
|
||||
|
||||
## AgentSettings
|
||||
|
||||
### `default`
|
||||
|
||||
`map[string]any`
|
||||
|
||||
Default is agent.yaml's keys for every node.
|
||||
|
||||
### `nodes`
|
||||
|
||||
`map[string]map[string]any`
|
||||
|
||||
Nodes are agent.yaml's keys for one node each, by node id, over
|
||||
Default.
|
||||
|
||||
## Auth
|
||||
|
||||
### `enabled`
|
||||
@@ -2520,6 +2549,18 @@ Firewall turns llmgw's firewall on every machine on or off.
|
||||
|
||||
Takes a [FirewallSettings](#firewallsettings) block.
|
||||
|
||||
### `agents`
|
||||
|
||||
`AgentSettings`
|
||||
|
||||
Agents are the nodes' agent settings: agent.yaml's keys but those an
|
||||
agent needs to start, for every node (default) and for one
|
||||
(nodes.<id>). Sent to the agents as they change; an agent whose
|
||||
settings changed starts again on them, its engines serving
|
||||
throughout.
|
||||
|
||||
Takes a [AgentSettings](#agentsettings) block.
|
||||
|
||||
### `firewall_networks`
|
||||
|
||||
`[]string`
|
||||
|
||||
@@ -18,6 +18,7 @@ require (
|
||||
github.com/knadh/koanf/providers/env/v2 v2.0.1
|
||||
github.com/knadh/koanf/providers/file v1.2.1
|
||||
github.com/knadh/koanf/v2 v2.3.6
|
||||
github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0
|
||||
github.com/mattn/go-isatty v0.0.24
|
||||
github.com/prometheus/client_golang v1.24.1
|
||||
github.com/prometheus/client_model v0.6.3
|
||||
@@ -34,6 +35,7 @@ require (
|
||||
golang.org/x/net v0.59.0
|
||||
golang.org/x/oauth2 v0.37.0
|
||||
golang.org/x/sync v0.23.0
|
||||
golang.org/x/term v0.46.0
|
||||
golang.org/x/text v0.42.0
|
||||
google.golang.org/protobuf v1.36.12
|
||||
modernc.org/sqlite v1.59.0
|
||||
|
||||
@@ -95,6 +95,8 @@ github.com/knadh/koanf/v2 v2.3.6 h1:JoQPSJmvS4aP0xNc8xMDr5tcrkSEInL23/Il7pITAKo=
|
||||
github.com/knadh/koanf/v2 v2.3.6/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0 h1:7Q+xNAZFmnfYOMweHN3c/PDFUKKfY1pVJ26K++QvVfU=
|
||||
github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
|
||||
@@ -34,6 +34,7 @@ import (
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/engines"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/provision"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/supervisor"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/train"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/weights"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/agentsvc"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/firewall"
|
||||
@@ -136,6 +137,13 @@ type Agent struct {
|
||||
// by mu.
|
||||
kv *kvevents.Mirror
|
||||
kvSubs map[string]*kvevents.Subscriber
|
||||
|
||||
// settingsCheck says whether the cluster's settings for this agent
|
||||
// would load beside its agent.yaml; see SetSettingsCheck.
|
||||
settingsCheck func([]byte) error
|
||||
|
||||
// trainer runs LoRA training jobs, one at a time (BACKLOG F4).
|
||||
trainer *train.Runner
|
||||
}
|
||||
|
||||
// connectHTTPClient is the HTTP client connect uses; an interface so tests can
|
||||
@@ -182,6 +190,18 @@ func New(cfg Config, httpClient connectHTTPClient, log *slog.Logger) (*Agent, er
|
||||
kvSubs: map[string]*kvevents.Subscriber{},
|
||||
}
|
||||
a.prov = &provision.Installer{Root: cfg.EnginesDir, Log: log, OnStatus: a.provisionStatus}
|
||||
runAs := cfg.Superv.RunAs
|
||||
if runAs == "" {
|
||||
runAs = supervisor.DefaultRunAs
|
||||
}
|
||||
// Where the engines are, as the installer has it when the file says
|
||||
// nothing.
|
||||
enginesDir := cfg.EnginesDir
|
||||
if enginesDir == "" {
|
||||
enginesDir = provision.DefaultRoot
|
||||
}
|
||||
a.trainer = train.New(train.Env{EnginesDir: enginesDir, WorkDir: filepath.Join(cfg.Superv.WorkDir, "trainer"),
|
||||
RunAs: runAs, Log: log})
|
||||
return a, nil
|
||||
}
|
||||
|
||||
@@ -880,6 +900,12 @@ func (a *Agent) handle(ctx context.Context, msg *llmgwv1.ControlMessage, out cha
|
||||
a.setControlPlanes(m.ControlPlanes)
|
||||
case *llmgwv1.ControlMessage_Firewall:
|
||||
go a.applyFirewall(m.Firewall)
|
||||
case *llmgwv1.ControlMessage_AgentSettings:
|
||||
go a.applySettings(m.AgentSettings)
|
||||
case *llmgwv1.ControlMessage_TrainStart:
|
||||
go a.trainJob(ctx, m.TrainStart, out)
|
||||
case *llmgwv1.ControlMessage_TrainCancel:
|
||||
a.trainer.Cancel(m.TrainCancel.GetJobId())
|
||||
case *llmgwv1.ControlMessage_RestartAgent:
|
||||
go func() {
|
||||
a.log.Info("restarting, as the control plane asks; the engines keep serving",
|
||||
|
||||
@@ -103,6 +103,11 @@ type Config struct {
|
||||
Tracing obs.Tracing `koanf:"tracing"`
|
||||
|
||||
Version string
|
||||
|
||||
// Settings is the cluster's settings for this agent it was started on,
|
||||
// as the control plane sent them: what a newly sent version is
|
||||
// compared with.
|
||||
Settings string `koanf:"-"`
|
||||
}
|
||||
|
||||
// DefaultConfig returns sensible defaults.
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
|
||||
)
|
||||
|
||||
// The cluster's settings for this agent (BACKLOG E3): what agent.yaml can
|
||||
// say but the agent's id, control planes, address and certificate, kept by
|
||||
// the control planes and sent over the stream. Kept here beside agent.yaml,
|
||||
// which then holds only what the agent needs to start; at start the two are
|
||||
// read together, these over the file.
|
||||
|
||||
// SettingsFile keeps the settings the control plane last sent.
|
||||
var SettingsFile = "/var/lib/llmgw-agent/agent-settings.yaml"
|
||||
|
||||
// ReadSettings is what SettingsFile holds, nil when nothing.
|
||||
func ReadSettings() []byte {
|
||||
b, err := os.ReadFile(SettingsFile)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// SetSettingsCheck says how to tell whether settings would load beside this
|
||||
// agent's own file; settings that would not are not taken.
|
||||
func (a *Agent) SetSettingsCheck(f func([]byte) error) { a.settingsCheck = f }
|
||||
|
||||
// applySettings takes the settings the control plane sent: nothing when they
|
||||
// are what this agent runs on, and otherwise kept and started again on.
|
||||
func (a *Agent) applySettings(m *llmgwv1.AgentSettings) {
|
||||
next := m.GetYaml()
|
||||
if next == a.cfg.Settings {
|
||||
return
|
||||
}
|
||||
if a.settingsCheck != nil {
|
||||
if err := a.settingsCheck([]byte(next)); err != nil {
|
||||
// Kept running on what it has: settings that will not load
|
||||
// here must not take the node out of service.
|
||||
a.log.Error("the cluster's settings for this agent do not load beside its agent.yaml; running on what it has",
|
||||
"err", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(SettingsFile), 0o755); err != nil {
|
||||
a.log.Error("could not keep the cluster's settings for this agent", "err", err)
|
||||
return
|
||||
}
|
||||
tmp := SettingsFile + ".new"
|
||||
if err := os.WriteFile(tmp, []byte(next), 0o600); err != nil {
|
||||
a.log.Error("could not keep the cluster's settings for this agent", "err", err)
|
||||
return
|
||||
}
|
||||
if err := os.Rename(tmp, SettingsFile); err != nil {
|
||||
a.log.Error("could not keep the cluster's settings for this agent", "err", err)
|
||||
return
|
||||
}
|
||||
a.log.Info("the cluster's settings for this agent changed; starting again on them, the engines keep serving")
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
exitForUpgrade()
|
||||
}
|
||||
|
||||
// WatchFile starts the agent again when its configuration file changes and
|
||||
// still loads: agent.yaml is read only at start, and a change to it that
|
||||
// waited for somebody to restart the agent was a change nobody saw take
|
||||
// effect.
|
||||
func (a *Agent) WatchFile(ctx context.Context, path string, loads func() error) {
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
sum := func() [32]byte {
|
||||
b, _ := os.ReadFile(path) //nolint:gosec // the agent's own configuration
|
||||
return sha256.Sum256(b)
|
||||
}
|
||||
was := sum()
|
||||
t := time.NewTicker(watchEvery)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
now := sum()
|
||||
if now == was {
|
||||
continue
|
||||
}
|
||||
if err := loads(); err != nil {
|
||||
// Once per change: an editor saving halfway must not stop the
|
||||
// agent, and a broken file is reported, not restarted into.
|
||||
a.log.Error("agent.yaml changed and does not load; running on what it had", "path", path, "err", err)
|
||||
was = now
|
||||
continue
|
||||
}
|
||||
a.log.Info("agent.yaml changed; starting again on it, the engines keep serving", "path", path)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
exitForUpgrade()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// watchEvery is how often agent.yaml is looked at.
|
||||
var watchEvery = 10 * time.Second
|
||||
@@ -0,0 +1,88 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
|
||||
)
|
||||
|
||||
func settingsAgent(t *testing.T, running string) (*Agent, chan struct{}) {
|
||||
t.Helper()
|
||||
oldFile, oldExit := SettingsFile, exitForUpgrade
|
||||
SettingsFile = filepath.Join(t.TempDir(), "agent-settings.yaml")
|
||||
exited := make(chan struct{}, 1)
|
||||
exitForUpgrade = func() { exited <- struct{}{} }
|
||||
t.Cleanup(func() { SettingsFile, exitForUpgrade = oldFile, oldExit })
|
||||
cfg := Config{Server: "127.0.0.1:7443", NodeID: "n1", WeightsCache: t.TempDir(), EnginesDir: t.TempDir(), Settings: running}
|
||||
cfg.Superv.WorkDir = t.TempDir()
|
||||
a, err := New(cfg, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return a, exited
|
||||
}
|
||||
|
||||
// exitedSoon is whether the agent exited within a second: it waits a
|
||||
// moment for its log line to leave first.
|
||||
func exitedSoon(ch chan struct{}) bool {
|
||||
select {
|
||||
case <-ch:
|
||||
return true
|
||||
case <-time.After(time.Second):
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAgentOnItsSettingsStaysAndOneOffThemStartsAgain(t *testing.T) {
|
||||
a, exited := settingsAgent(t, "heartbeat_interval: 5s\n")
|
||||
a.applySettings(&llmgwv1.AgentSettings{Yaml: "heartbeat_interval: 5s\n"})
|
||||
if exitedSoon(exited) || ReadSettings() != nil {
|
||||
t.Error("settings it already runs on restarted it")
|
||||
}
|
||||
a.applySettings(&llmgwv1.AgentSettings{Yaml: "heartbeat_interval: 2s\n"})
|
||||
if !exitedSoon(exited) || string(ReadSettings()) != "heartbeat_interval: 2s\n" {
|
||||
t.Errorf("new settings: kept %q", ReadSettings())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsThatWouldNotLoadAreNotTaken(t *testing.T) {
|
||||
a, exited := settingsAgent(t, "")
|
||||
a.SetSettingsCheck(func([]byte) error { return errors.New("fabric ib-a lists no HCAs") })
|
||||
a.applySettings(&llmgwv1.AgentSettings{Yaml: "fabrics: [{name: ib-a}]\n"})
|
||||
if exitedSoon(exited) || ReadSettings() != nil {
|
||||
t.Error("settings that do not load were taken")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAChangedAgentYamlThatLoadsStartsTheAgentAgain(t *testing.T) {
|
||||
a, exited := settingsAgent(t, "")
|
||||
old := watchEvery
|
||||
watchEvery = 10 * time.Millisecond
|
||||
t.Cleanup(func() { watchEvery = old })
|
||||
p := filepath.Join(t.TempDir(), "agent.yaml")
|
||||
_ = os.WriteFile(p, []byte("node_id: n1\n"), 0o600)
|
||||
var loads atomic.Bool
|
||||
check := func() error {
|
||||
if loads.Load() {
|
||||
return nil
|
||||
}
|
||||
return errors.New("broken")
|
||||
}
|
||||
go a.WatchFile(t.Context(), p, check)
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
// Saved broken: reported, not restarted into.
|
||||
_ = os.WriteFile(p, []byte("node_id: [\n"), 0o600)
|
||||
if exitedSoon(exited) {
|
||||
t.Fatal("a broken agent.yaml restarted the agent")
|
||||
}
|
||||
loads.Store(true)
|
||||
_ = os.WriteFile(p, []byte("node_id: n1\nlabels: {a: b}\n"), 0o600)
|
||||
if !exitedSoon(exited) {
|
||||
t.Error("a changed agent.yaml did not restart the agent")
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package supervisor
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -90,3 +91,25 @@ func credentialFor(u *user.User, workDir string) (*runAs, error) {
|
||||
func (r *runAs) env() []string {
|
||||
return []string{"HOME=" + r.home, "XDG_CACHE_HOME=" + filepath.Join(r.home, ".cache"), "USER=" + r.name, "LOGNAME=" + r.name}
|
||||
}
|
||||
|
||||
// AsEngineUser has cmd run as the engine user, as an engine would, with its
|
||||
// home under workDir: for work the agent does that is not an engine but
|
||||
// reads what an engine reads — training an adapter. It answers that home
|
||||
// and a way to hand a path to the user, or why it runs as this process.
|
||||
func AsEngineUser(cmd *exec.Cmd, name, workDir string) (home string, give func(path string) error, why string) {
|
||||
r, why := resolveRunAs(name, workDir)
|
||||
if r == nil {
|
||||
return "", func(string) error { return nil }, why
|
||||
}
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Credential: r.cred, Setpgid: true}
|
||||
cmd.Env = append(cmd.Env, r.env()...)
|
||||
uid, gid := int(r.cred.Uid), int(r.cred.Gid)
|
||||
return r.home, func(path string) error {
|
||||
return filepath.WalkDir(path, func(p string, _ os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Lchown(p, uid, gid)
|
||||
})
|
||||
}, ""
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
// Package train runs LoRA training jobs on a node (BACKLOG F4): with an
|
||||
// installed engine's Python — its torch and transformers, already matched to
|
||||
// the node's CUDA — and a small overlay of what training needs besides, so a
|
||||
// node that serves can train without a second multi-gigabyte environment.
|
||||
package train
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
_ "embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/supervisor"
|
||||
)
|
||||
|
||||
//go:embed trainer.py
|
||||
var trainerPy []byte
|
||||
|
||||
// overlayPackages are what training needs besides an engine's Python, pinned
|
||||
// and installed without their dependencies: those are the engine's own, and
|
||||
// letting an installer resolve them pulled a second torch.
|
||||
var overlayPackages = []string{"peft==0.21.2", "accelerate==1.15.0"}
|
||||
|
||||
// Job is one training job, as the control plane sent it.
|
||||
type Job struct {
|
||||
ID string
|
||||
BaseRepo string
|
||||
BaseRevision string
|
||||
// Data is NDJSON of {"q", "a"}.
|
||||
Data []byte
|
||||
// Params is the trainer's settings as JSON.
|
||||
Params string
|
||||
GPUMemoryMiB int
|
||||
GGUF bool
|
||||
}
|
||||
|
||||
// Status is how a job is going.
|
||||
type Status struct {
|
||||
State string
|
||||
Message string
|
||||
Step int
|
||||
Steps int
|
||||
Loss float64
|
||||
PeakMiB int
|
||||
}
|
||||
|
||||
// Env is where a node keeps what training needs.
|
||||
type Env struct {
|
||||
// EnginesDir is where engines are installed (the agent's engines_dir).
|
||||
EnginesDir string
|
||||
// WorkDir is the trainer's own directory: its overlay, the llama.cpp
|
||||
// conversion code, and each job's files while it runs.
|
||||
WorkDir string
|
||||
// RunAs is the user jobs run as: the engines' user.
|
||||
RunAs string
|
||||
Log *slog.Logger
|
||||
// Python, UV, ConvDir and GPUs replace what would be found, for tests.
|
||||
Python, UV, ConvDir string
|
||||
GPUs func(ctx context.Context) ([]GPU, error)
|
||||
}
|
||||
|
||||
// GPU is one card and how much of it is free.
|
||||
type GPU struct {
|
||||
Index int
|
||||
FreeMiB int
|
||||
}
|
||||
|
||||
// Runner runs one job at a time.
|
||||
type Runner struct {
|
||||
env Env
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc
|
||||
job string
|
||||
}
|
||||
|
||||
// New is a runner.
|
||||
func New(env Env) *Runner {
|
||||
if env.Log == nil {
|
||||
env.Log = slog.New(slog.DiscardHandler)
|
||||
}
|
||||
return &Runner{env: env}
|
||||
}
|
||||
|
||||
// Cancel stops a job if it is the one running.
|
||||
func (r *Runner) Cancel(id string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.job == id && r.cancel != nil {
|
||||
r.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// ErrBusy is a node already training.
|
||||
var ErrBusy = errors.New("this node is already training")
|
||||
|
||||
// Run runs a job to its end, telling status as it goes and artifact each
|
||||
// piece of what it made. It answers the error it failed with.
|
||||
func (r *Runner) Run(ctx context.Context, job Job, status func(Status), artifact func(kind string, seq int, data []byte, last bool) error) error {
|
||||
r.mu.Lock()
|
||||
if r.job != "" {
|
||||
r.mu.Unlock()
|
||||
return ErrBusy
|
||||
}
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
r.job, r.cancel = job.ID, cancel
|
||||
r.mu.Unlock()
|
||||
defer func() {
|
||||
cancel()
|
||||
r.mu.Lock()
|
||||
r.job, r.cancel = "", nil
|
||||
r.mu.Unlock()
|
||||
}()
|
||||
|
||||
status(Status{State: "preparing", Message: "preparing the trainer"})
|
||||
py, err := r.python()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
overlay, err := r.overlay(ctx, py)
|
||||
if err != nil {
|
||||
return fmt.Errorf("installing %s: %w", strings.Join(overlayPackages, ", "), err)
|
||||
}
|
||||
conv := ""
|
||||
if job.GGUF {
|
||||
if conv, err = r.conversion(ctx); err != nil {
|
||||
return fmt.Errorf("fetching llama.cpp's conversion code: %w", err)
|
||||
}
|
||||
}
|
||||
gpu, err := r.pickGPU(ctx, job.GPUMemoryMiB)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dir := filepath.Join(r.env.WorkDir, "jobs", job.ID)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
out := filepath.Join(dir, "adapter")
|
||||
var params map[string]any
|
||||
if job.Params != "" {
|
||||
if err := json.Unmarshal([]byte(job.Params), ¶ms); err != nil {
|
||||
return fmt.Errorf("the job's params: %w", err)
|
||||
}
|
||||
}
|
||||
spec, _ := json.Marshal(map[string]any{"base_repo": job.BaseRepo, "base_revision": job.BaseRevision,
|
||||
"data": filepath.Join(dir, "data.jsonl"), "out": out, "params": params})
|
||||
for name, b := range map[string][]byte{"data.jsonl": job.Data, "job.json": spec, "trainer.py": trainerPy} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), b, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var baseDir string
|
||||
err = r.python3(ctx, py, dir, []string{"PYTHONPATH=" + overlay, "CUDA_VISIBLE_DEVICES=" + strconv.Itoa(gpu)},
|
||||
[]string{filepath.Join(dir, "trainer.py"), filepath.Join(dir, "job.json")},
|
||||
func(line map[string]any) {
|
||||
s := Status{State: str(line["state"]), Message: str(line["message"]), Step: num(line["step"]),
|
||||
Steps: num(line["steps"]), PeakMiB: num(line["peak_mib"])}
|
||||
if v, ok := line["loss"].(float64); ok {
|
||||
s.Loss = v
|
||||
}
|
||||
if b := str(line["base_dir"]); b != "" {
|
||||
baseDir = b
|
||||
}
|
||||
if s.State == "trained" {
|
||||
s.State = "running"
|
||||
s.Message = "trained; packing the adapter"
|
||||
}
|
||||
status(s)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if job.GGUF {
|
||||
status(Status{State: "running", Message: "converting the adapter to GGUF for llama.cpp"})
|
||||
gguf := filepath.Join(dir, "adapter.gguf")
|
||||
err := r.python3(ctx, py, dir, []string{"PYTHONPATH=" + strings.Join([]string{filepath.Join(conv, "gguf-py"), conv, overlay}, ":")},
|
||||
[]string{filepath.Join(conv, "convert_lora_to_gguf.py"), "--base", baseDir, "--outtype", "f16", "--outfile", gguf, out}, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("converting to GGUF: %w", err)
|
||||
}
|
||||
if err := sendFile(gguf, "gguf", artifact); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var tgz bytes.Buffer
|
||||
if err := tarGz(&tgz, out); err != nil {
|
||||
return err
|
||||
}
|
||||
return sendBytes(tgz.Bytes(), "peft", artifact)
|
||||
}
|
||||
|
||||
func str(v any) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func num(v any) int {
|
||||
f, _ := v.(float64)
|
||||
return int(f)
|
||||
}
|
||||
|
||||
// python3 runs a Python script as the engines' user, passing each JSON line
|
||||
// it prints to line, and answers the last of what it wrote to stderr when it
|
||||
// fails.
|
||||
func (r *Runner) python3(ctx context.Context, py, dir string, env, args []string, line func(map[string]any)) error {
|
||||
cmd := exec.CommandContext(ctx, py, args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True", "TOKENIZERS_PARALLELISM=false",
|
||||
"HF_HUB_DISABLE_PROGRESS_BARS=1", "PYTHONUNBUFFERED=1")
|
||||
cmd.Env = append(cmd.Env, env...)
|
||||
home, give, why := supervisor.AsEngineUser(cmd, r.env.RunAs, r.env.WorkDir)
|
||||
if why != "" {
|
||||
r.env.Log.Warn(why)
|
||||
}
|
||||
if home != "" {
|
||||
cmd.Env = append(cmd.Env, "HF_HOME="+filepath.Join(home, ".cache", "huggingface"))
|
||||
} else {
|
||||
cmd.Env = append(cmd.Env, "HF_HOME="+filepath.Join(r.env.WorkDir, "hf"))
|
||||
}
|
||||
if err := give(dir); err != nil {
|
||||
return fmt.Errorf("handing %s to %s: %w", dir, r.env.RunAs, err)
|
||||
}
|
||||
// The whole process group goes when the job is cancelled: torch starts
|
||||
// workers of its own.
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
}
|
||||
cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) }
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var stderr tailBuffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
sc := bufio.NewScanner(stdout)
|
||||
sc.Buffer(make([]byte, 64<<10), 1<<20)
|
||||
for sc.Scan() {
|
||||
var m map[string]any
|
||||
if line != nil && json.Unmarshal(sc.Bytes(), &m) == nil {
|
||||
line(m)
|
||||
}
|
||||
}
|
||||
err = cmd.Wait()
|
||||
if ctx.Err() != nil {
|
||||
return errors.New("cancelled")
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("%v: %s", err, stderr.last())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tailBuffer keeps the end of what a process wrote: the reason it failed is
|
||||
// at the end, and the rest is progress bars.
|
||||
type tailBuffer struct{ b []byte }
|
||||
|
||||
func (t *tailBuffer) Write(p []byte) (int, error) {
|
||||
t.b = append(t.b, p...)
|
||||
if len(t.b) > 16<<10 {
|
||||
t.b = t.b[len(t.b)-16<<10:]
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (t *tailBuffer) last() string {
|
||||
lines := strings.Split(strings.TrimSpace(string(t.b)), "\n")
|
||||
if len(lines) > 3 {
|
||||
lines = lines[len(lines)-3:]
|
||||
}
|
||||
return strings.Join(lines, " | ")
|
||||
}
|
||||
|
||||
// python is the newest installed vLLM's interpreter: its torch is built for
|
||||
// this node's CUDA.
|
||||
func (r *Runner) python() (string, error) {
|
||||
if r.env.Python != "" {
|
||||
return r.env.Python, nil
|
||||
}
|
||||
found, _ := filepath.Glob(filepath.Join(r.env.EnginesDir, "vllm", "*", "bin", "python3"))
|
||||
if len(found) == 0 {
|
||||
return "", errors.New("training needs vLLM installed on this node (its torch is what trains); " +
|
||||
"llmgw ctl engine-build, or serving any vLLM model here, installs it")
|
||||
}
|
||||
sort.Strings(found)
|
||||
return found[len(found)-1], nil
|
||||
}
|
||||
|
||||
func (r *Runner) uv() (string, error) {
|
||||
if r.env.UV != "" {
|
||||
return r.env.UV, nil
|
||||
}
|
||||
var found []string
|
||||
_ = filepath.WalkDir(filepath.Join(r.env.EnginesDir, "uv"), func(p string, d os.DirEntry, err error) error {
|
||||
if err == nil && !d.IsDir() && d.Name() == "uv" {
|
||||
found = append(found, p)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if len(found) == 0 {
|
||||
return "", errors.New("no uv under " + filepath.Join(r.env.EnginesDir, "uv"))
|
||||
}
|
||||
sort.Strings(found)
|
||||
return found[len(found)-1], nil
|
||||
}
|
||||
|
||||
// overlay installs the pinned packages once per Python and set of pins.
|
||||
func (r *Runner) overlay(ctx context.Context, py string) (string, error) {
|
||||
sum := sha256.Sum256([]byte(py + "\x00" + strings.Join(overlayPackages, " ")))
|
||||
dir := filepath.Join(r.env.WorkDir, "overlay-"+hex.EncodeToString(sum[:6]))
|
||||
if _, err := os.Stat(filepath.Join(dir, ".done")); err == nil {
|
||||
return dir, nil
|
||||
}
|
||||
uv, err := r.uv()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmp := dir + ".new"
|
||||
_ = os.RemoveAll(tmp)
|
||||
args := append([]string{"pip", "install", "--quiet", "--python", py, "--no-deps", "--target", tmp}, overlayPackages...)
|
||||
cmd := exec.CommandContext(ctx, uv, args...)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return "", fmt.Errorf("%v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(tmp, ".done"), nil, 0o644)
|
||||
_ = os.RemoveAll(dir)
|
||||
if err := os.Rename(tmp, dir); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Readable by the engines' user, which runs the trainer.
|
||||
_ = filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error {
|
||||
if err == nil {
|
||||
mode := os.FileMode(0o644)
|
||||
if d.IsDir() {
|
||||
mode = 0o755
|
||||
}
|
||||
_ = os.Chmod(p, mode)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// llamaCppTag is the release of the newest llama.cpp installed: its
|
||||
// conversion code is the one whose GGUF that llama.cpp reads.
|
||||
func (r *Runner) llamaCppTag() (string, error) {
|
||||
found, _ := filepath.Glob(filepath.Join(r.env.EnginesDir, "llamacpp", "b*"))
|
||||
if len(found) == 0 {
|
||||
return "", errors.New("a GGUF adapter needs llama.cpp installed on this node, to match its release")
|
||||
}
|
||||
sort.Strings(found)
|
||||
tag, _, _ := strings.Cut(filepath.Base(found[len(found)-1]), "-")
|
||||
return tag, nil
|
||||
}
|
||||
|
||||
// conversionSource is where llama.cpp's source is fetched from, by tag.
|
||||
var conversionSource = "https://codeload.github.com/ggml-org/llama.cpp/tar.gz/refs/tags/"
|
||||
|
||||
// conversion fetches llama.cpp's LoRA conversion code for the installed
|
||||
// release, once.
|
||||
func (r *Runner) conversion(ctx context.Context) (string, error) {
|
||||
if r.env.ConvDir != "" {
|
||||
return r.env.ConvDir, nil
|
||||
}
|
||||
tag, err := r.llamaCppTag()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir := filepath.Join(r.env.WorkDir, "llama.cpp-"+tag)
|
||||
if _, err := os.Stat(filepath.Join(dir, "convert_lora_to_gguf.py")); err == nil {
|
||||
return dir, nil
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, conversionSource+tag, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("%s answered %s", conversionSource+tag, res.Status)
|
||||
}
|
||||
tmp := dir + ".new"
|
||||
_ = os.RemoveAll(tmp)
|
||||
if err := untarConversion(res.Body, tmp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = os.RemoveAll(dir)
|
||||
return dir, os.Rename(tmp, dir)
|
||||
}
|
||||
|
||||
// untarConversion keeps, of llama.cpp's source, the conversion scripts and
|
||||
// the Python packages they import.
|
||||
func untarConversion(r io.Reader, dir string) error {
|
||||
gz, err := gzip.NewReader(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tr := tar.NewReader(gz)
|
||||
kept := 0
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, rel, ok := strings.Cut(h.Name, "/")
|
||||
if !ok || h.Typeflag != tar.TypeReg || strings.Contains(rel, "..") {
|
||||
continue
|
||||
}
|
||||
if rel != "convert_lora_to_gguf.py" && rel != "convert_hf_to_gguf.py" &&
|
||||
!strings.HasPrefix(rel, "conversion/") && !strings.HasPrefix(rel, "gguf-py/") {
|
||||
continue
|
||||
}
|
||||
p := filepath.Join(dir, filepath.FromSlash(rel))
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(f, io.LimitReader(tr, 64<<20))
|
||||
f.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kept++
|
||||
}
|
||||
if kept == 0 {
|
||||
return errors.New("no conversion code in llama.cpp's source")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pickGPU is the card with the most memory free, if that is enough.
|
||||
func (r *Runner) pickGPU(ctx context.Context, need int) (int, error) {
|
||||
list := r.env.GPUs
|
||||
if list == nil {
|
||||
list = nvidiaSMI
|
||||
}
|
||||
gpus, err := list(ctx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("reading the cards' free memory: %w", err)
|
||||
}
|
||||
if len(gpus) == 0 {
|
||||
return 0, errors.New("this node has no GPU to train on")
|
||||
}
|
||||
best := gpus[0]
|
||||
for _, g := range gpus[1:] {
|
||||
if g.FreeMiB > best.FreeMiB {
|
||||
best = g
|
||||
}
|
||||
}
|
||||
if best.FreeMiB < need {
|
||||
return 0, fmt.Errorf("no card here has %d MiB free (the most free is GPU %d with %d MiB); "+
|
||||
"a smaller base, or a card freed for it, would do", need, best.Index, best.FreeMiB)
|
||||
}
|
||||
return best.Index, nil
|
||||
}
|
||||
|
||||
func nvidiaSMI(ctx context.Context) ([]GPU, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx, "nvidia-smi", "--query-gpu=index,memory.free", "--format=csv,noheader,nounits").Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseGPUs(string(out)), nil
|
||||
}
|
||||
|
||||
func parseGPUs(s string) []GPU {
|
||||
var out []GPU
|
||||
for _, line := range strings.Split(strings.TrimSpace(s), "\n") {
|
||||
a, b, ok := strings.Cut(line, ",")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
i, err1 := strconv.Atoi(strings.TrimSpace(a))
|
||||
f, err2 := strconv.Atoi(strings.TrimSpace(b))
|
||||
if err1 == nil && err2 == nil {
|
||||
out = append(out, GPU{Index: i, FreeMiB: f})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tarGz packs a directory.
|
||||
func tarGz(w io.Writer, dir string) error {
|
||||
gz := gzip.NewWriter(w)
|
||||
tw := tar.NewWriter(gz)
|
||||
err := filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(dir, p)
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tw.WriteHeader(&tar.Header{Name: filepath.ToSlash(rel), Mode: 0o644, Size: info.Size(), ModTime: info.ModTime()}); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.Open(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(tw, f)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return gz.Close()
|
||||
}
|
||||
|
||||
// artifactChunk is how much of an artifact goes in one message.
|
||||
const artifactChunk = 1 << 20
|
||||
|
||||
func sendFile(path, kind string, send func(string, int, []byte, bool) error) error {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sendBytes(b, kind, send)
|
||||
}
|
||||
|
||||
func sendBytes(b []byte, kind string, send func(string, int, []byte, bool) error) error {
|
||||
seq := 0
|
||||
for {
|
||||
n := min(artifactChunk, len(b))
|
||||
last := n == len(b)
|
||||
if err := send(kind, seq, b[:n], last); err != nil {
|
||||
return err
|
||||
}
|
||||
if last {
|
||||
return nil
|
||||
}
|
||||
b = b[n:]
|
||||
seq++
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package train
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakePython stands in for an engine's interpreter: as the trainer it
|
||||
// prints progress and writes an adapter; as the converter it writes a GGUF;
|
||||
// told to fail, it fails.
|
||||
const fakePython = `#!/bin/sh
|
||||
case "$1" in
|
||||
*trainer.py)
|
||||
out=$(sed -n 's/.*"out": *"\([^"]*\)".*/\1/p' "$2")
|
||||
grep -q FAIL "$(dirname "$2")/data.jsonl" && { echo "CUDA out of memory" >&2; exit 1; }
|
||||
echo '{"state":"preparing","message":"fetching base"}'
|
||||
echo '{"state":"running","step":5,"steps":10,"loss":0.9,"peak_mib":1900}'
|
||||
mkdir -p "$out"; echo '{"r":16}' > "$out/adapter_config.json"; echo weights > "$out/adapter_model.safetensors"
|
||||
echo '{"state":"trained","step":10,"steps":10,"peak_mib":2100,"base_dir":"/base"}'
|
||||
;;
|
||||
*convert_lora_to_gguf.py)
|
||||
while [ $# -gt 0 ]; do [ "$1" = "--outfile" ] && echo GGUF > "$2"; shift; done
|
||||
;;
|
||||
esac
|
||||
`
|
||||
|
||||
func runner(t *testing.T, free int) *Runner {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
py := filepath.Join(dir, "python3")
|
||||
_ = os.WriteFile(py, []byte(fakePython), 0o755)
|
||||
uv := filepath.Join(dir, "uv")
|
||||
_ = os.WriteFile(uv, []byte("#!/bin/sh\nwhile [ $# -gt 0 ]; do [ \"$1\" = \"--target\" ] && mkdir -p \"$2\"; shift; done\n"), 0o755)
|
||||
return New(Env{WorkDir: filepath.Join(dir, "work"), Python: py, UV: uv, ConvDir: dir,
|
||||
GPUs: func(context.Context) ([]GPU, error) { return []GPU{{0, 500}, {1, free}}, nil }})
|
||||
}
|
||||
|
||||
type sink struct {
|
||||
mu sync.Mutex
|
||||
statuses []Status
|
||||
parts map[string][]byte
|
||||
lasts int
|
||||
}
|
||||
|
||||
func (s *sink) status(st Status) { s.mu.Lock(); s.statuses = append(s.statuses, st); s.mu.Unlock() }
|
||||
func (s *sink) artifact(kind string, _ int, b []byte, last bool) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.parts == nil {
|
||||
s.parts = map[string][]byte{}
|
||||
}
|
||||
s.parts[kind] = append(s.parts[kind], b...)
|
||||
if last {
|
||||
s.lasts++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAJobTrainsOnTheFreestCardAndSendsWhatItMade(t *testing.T) {
|
||||
r := runner(t, 3000)
|
||||
s := &sink{}
|
||||
err := r.Run(context.Background(), Job{ID: "j1", BaseRepo: "Qwen/x", Data: []byte(`{"q":"a","a":"b"}`), GPUMemoryMiB: 2500, GGUF: true},
|
||||
s.status, s.artifact)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var running bool
|
||||
for _, st := range s.statuses {
|
||||
if st.State == "running" && st.Step == 5 && st.Steps == 10 && st.PeakMiB == 1900 {
|
||||
running = true
|
||||
}
|
||||
}
|
||||
if !running {
|
||||
t.Errorf("statuses %+v", s.statuses)
|
||||
}
|
||||
if string(s.parts["gguf"]) != "GGUF\n" || s.lasts != 2 {
|
||||
t.Errorf("gguf %q, lasts %d", s.parts["gguf"], s.lasts)
|
||||
}
|
||||
files := map[string]string{}
|
||||
gz, err := gzip.NewReader(bytes.NewReader(s.parts["peft"]))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
b, _ := io.ReadAll(tr)
|
||||
files[h.Name] = string(b)
|
||||
}
|
||||
if files["adapter_model.safetensors"] != "weights\n" || files["adapter_config.json"] == "" {
|
||||
t.Errorf("peft archive %v", files)
|
||||
}
|
||||
// Nothing of the job is left on the node.
|
||||
if entries, _ := os.ReadDir(filepath.Join(r.env.WorkDir, "jobs")); len(entries) != 0 {
|
||||
t.Errorf("left behind: %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAJobWaitsForNoCardItDoesNotFit(t *testing.T) {
|
||||
r := runner(t, 1000)
|
||||
err := r.Run(context.Background(), Job{ID: "j1", Data: []byte(`{"q":"a","a":"b"}`), GPUMemoryMiB: 2500}, (&sink{}).status, (&sink{}).artifact)
|
||||
if err == nil || !strings.Contains(err.Error(), "no card here has 2500 MiB free") {
|
||||
t.Errorf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATrainerThatFailsSaysWhy(t *testing.T) {
|
||||
r := runner(t, 3000)
|
||||
err := r.Run(context.Background(), Job{ID: "j1", Data: []byte(`{"q":"FAIL","a":"b"}`)}, (&sink{}).status, (&sink{}).artifact)
|
||||
if err == nil || !strings.Contains(err.Error(), "CUDA out of memory") {
|
||||
t.Errorf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneJobAtATime(t *testing.T) {
|
||||
r := runner(t, 3000)
|
||||
r.job = "other"
|
||||
if err := r.Run(context.Background(), Job{ID: "j2"}, (&sink{}).status, (&sink{}).artifact); err != ErrBusy {
|
||||
t.Errorf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardsAreReadFromNvidiaSMI(t *testing.T) {
|
||||
got := parseGPUs("0, 2310\n1, 80000\nbad line\n")
|
||||
if len(got) != 2 || got[1].FreeMiB != 80000 || got[0].Index != 0 {
|
||||
t.Errorf("parsed %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyTheConversionCodeIsKeptOfLlamaCpp(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gz)
|
||||
for _, name := range []string{"llama.cpp-b1/convert_lora_to_gguf.py", "llama.cpp-b1/conversion/qwen.py",
|
||||
"llama.cpp-b1/gguf-py/gguf/__init__.py", "llama.cpp-b1/src/llama.cpp", "llama.cpp-b1/../evil"} {
|
||||
_ = tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: 1, Typeflag: tar.TypeReg})
|
||||
_, _ = tw.Write([]byte("x"))
|
||||
}
|
||||
_ = tw.Close()
|
||||
_ = gz.Close()
|
||||
dir := filepath.Join(t.TempDir(), "conv")
|
||||
if err := untarConversion(&buf, dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"convert_lora_to_gguf.py", "conversion/qwen.py", "gguf-py/gguf/__init__.py"} {
|
||||
if _, err := os.Stat(filepath.Join(dir, want)); err != nil {
|
||||
t.Errorf("%s not kept", want)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "src")); err == nil {
|
||||
t.Error("the rest of llama.cpp was kept")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
# llmgw's LoRA trainer (BACKLOG F4), run by the node agent with an installed
|
||||
# engine's Python (its torch and transformers) and a small overlay (peft,
|
||||
# accelerate). Reads a job, trains, writes the PEFT adapter, and says how it
|
||||
# goes as one JSON object a line on standard output.
|
||||
import json, math, os, random, sys, threading, subprocess, time
|
||||
|
||||
def say(**kw):
|
||||
print(json.dumps(kw), flush=True)
|
||||
|
||||
job = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||
p = {"rank": 16, "alpha": 32, "dropout": 0.05, "epochs": 6, "lr": 2e-4, "batch": 4, "max_len": 512, "seed": 0}
|
||||
p.update(job.get("params") or {})
|
||||
|
||||
say(state="preparing", message="loading libraries")
|
||||
import torch
|
||||
from huggingface_hub import snapshot_download
|
||||
from transformers import AutoModelForCausalLM, AutoTokenizer
|
||||
from peft import LoraConfig, get_peft_model
|
||||
|
||||
random.seed(p["seed"]); torch.manual_seed(p["seed"])
|
||||
if not torch.cuda.is_available():
|
||||
raise SystemExit("no CUDA device is visible to the trainer")
|
||||
|
||||
say(state="preparing", message="fetching " + job["base_repo"])
|
||||
base_dir = snapshot_download(job["base_repo"], revision=job.get("base_revision") or None,
|
||||
allow_patterns=["*.json", "*.safetensors", "*.txt", "*.model", "*.tiktoken"])
|
||||
say(state="preparing", message="loading the base model", base_dir=base_dir)
|
||||
|
||||
# bf16 where the card has it; fp16 with a loss scaler where it does not
|
||||
# (Turing and older).
|
||||
bf16 = torch.cuda.is_bf16_supported()
|
||||
dtype = torch.bfloat16 if bf16 else torch.float16
|
||||
tok = AutoTokenizer.from_pretrained(base_dir)
|
||||
if tok.pad_token_id is None:
|
||||
tok.pad_token = tok.eos_token
|
||||
model = AutoModelForCausalLM.from_pretrained(base_dir, dtype=dtype).to("cuda")
|
||||
|
||||
rows = [json.loads(l) for l in open(job["data"], encoding="utf-8") if l.strip()]
|
||||
rows = [r for r in rows if r.get("q") and r.get("a")]
|
||||
if not rows:
|
||||
raise SystemExit("no {q, a} pairs in the data")
|
||||
|
||||
def encode(r):
|
||||
prompt = tok.apply_chat_template([{"role": "user", "content": r["q"]}], add_generation_prompt=True, tokenize=False)
|
||||
full = tok.apply_chat_template([{"role": "user", "content": r["q"]}, {"role": "assistant", "content": r["a"]}], tokenize=False)
|
||||
pi = tok(prompt, add_special_tokens=False)["input_ids"]
|
||||
fi = tok(full, add_special_tokens=False)["input_ids"][: p["max_len"]]
|
||||
return fi, ([-100] * len(pi) + fi[len(pi):])[: len(fi)]
|
||||
|
||||
data = [encode(r) for r in rows]
|
||||
|
||||
model.gradient_checkpointing_enable()
|
||||
model.enable_input_require_grads()
|
||||
model = get_peft_model(model, LoraConfig(r=int(p["rank"]), lora_alpha=int(p["alpha"]), lora_dropout=float(p["dropout"]),
|
||||
task_type="CAUSAL_LM", target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]))
|
||||
trainable = [x for x in model.parameters() if x.requires_grad]
|
||||
for x in trainable: # fp32 trainable weights, so a loss scaler can unscale them
|
||||
x.data = x.data.float()
|
||||
|
||||
peak = {"mib": 0}
|
||||
stop = threading.Event()
|
||||
def watch():
|
||||
while not stop.is_set():
|
||||
try:
|
||||
out = subprocess.run(["nvidia-smi", "--query-compute-apps=pid,used_memory", "--format=csv,noheader,nounits"],
|
||||
capture_output=True, text=True, timeout=5).stdout
|
||||
for line in out.splitlines():
|
||||
pid, mib = [v.strip() for v in line.split(",")]
|
||||
if int(pid) == os.getpid():
|
||||
peak["mib"] = max(peak["mib"], int(mib))
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
threading.Thread(target=watch, daemon=True).start()
|
||||
|
||||
opt = torch.optim.AdamW(trainable, lr=float(p["lr"]), weight_decay=0.0)
|
||||
scaler = torch.amp.GradScaler("cuda", enabled=not bf16)
|
||||
bs, epochs = int(p["batch"]), int(p["epochs"])
|
||||
steps = epochs * math.ceil(len(data) / bs)
|
||||
warm = max(1, steps // 10)
|
||||
sched = torch.optim.lr_scheduler.LambdaLR(opt, lambda s: min(1.0, (s + 1) / warm) * max(0.0, 1 - s / steps))
|
||||
model.train(); step = 0; last = time.time()
|
||||
for ep in range(epochs):
|
||||
random.shuffle(data)
|
||||
for i in range(0, len(data), bs):
|
||||
batch = data[i:i + bs]; n = max(len(x[0]) for x in batch)
|
||||
ids = torch.tensor([x[0] + [tok.pad_token_id] * (n - len(x[0])) for x in batch], device="cuda")
|
||||
lab = torch.tensor([x[1] + [-100] * (n - len(x[1])) for x in batch], device="cuda")
|
||||
att = torch.tensor([[1] * len(x[0]) + [0] * (n - len(x[0])) for x in batch], device="cuda")
|
||||
with torch.autocast("cuda", dtype=dtype):
|
||||
loss = model(input_ids=ids, attention_mask=att, labels=lab).loss
|
||||
scaler.scale(loss).backward(); scaler.unscale_(opt)
|
||||
torch.nn.utils.clip_grad_norm_(trainable, 1.0)
|
||||
scaler.step(opt); scaler.update(); opt.zero_grad(set_to_none=True); sched.step(); step += 1
|
||||
if time.time() - last > 2 or step == steps:
|
||||
last = time.time()
|
||||
say(state="running", message="training", step=step, steps=steps, loss=round(loss.item(), 4), peak_mib=peak["mib"])
|
||||
|
||||
model.save_pretrained(job["out"])
|
||||
stop.set()
|
||||
say(state="trained", step=step, steps=steps, peak_mib=peak["mib"], base_dir=base_dir)
|
||||
@@ -0,0 +1,46 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/train"
|
||||
)
|
||||
|
||||
// trainJob runs a training job the control plane sent, telling it how the
|
||||
// job goes and sending what it made. A stream that ends ends the job: the
|
||||
// control plane, which sees the node go, marks it failed, and it is started
|
||||
// again from the beginning rather than resumed.
|
||||
func (a *Agent) trainJob(ctx context.Context, m *llmgwv1.TrainStart, out chan<- *llmgwv1.AgentMessage) {
|
||||
id := m.GetJobId()
|
||||
send := func(msg *llmgwv1.AgentMessage) bool {
|
||||
select {
|
||||
case out <- msg:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
status := func(s train.Status) {
|
||||
send(&llmgwv1.AgentMessage{Msg: &llmgwv1.AgentMessage_TrainStatus{TrainStatus: &llmgwv1.TrainStatus{
|
||||
JobId: id, State: s.State, Message: s.Message, Step: int32(s.Step), Steps: int32(s.Steps), //nolint:gosec // counts
|
||||
Loss: s.Loss, PeakMib: int32(s.PeakMiB)}}}) //nolint:gosec // MiB
|
||||
}
|
||||
artifact := func(kind string, seq int, data []byte, last bool) error {
|
||||
if !send(&llmgwv1.AgentMessage{Msg: &llmgwv1.AgentMessage_TrainArtifact{TrainArtifact: &llmgwv1.TrainArtifact{
|
||||
JobId: id, Kind: kind, Seq: int32(seq), Data: data, Last: last}}}) { //nolint:gosec // chunk count
|
||||
return ctx.Err()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
a.log.Info("training an adapter", "job", id, "base", m.GetBaseRepo())
|
||||
err := a.trainer.Run(ctx, train.Job{ID: id, BaseRepo: m.GetBaseRepo(), BaseRevision: m.GetBaseRevision(),
|
||||
Data: m.GetData(), Params: m.GetParams(), GPUMemoryMiB: int(m.GetGpuMemoryMib()), GGUF: m.GetGguf()}, status, artifact)
|
||||
if err != nil {
|
||||
a.log.Warn("training failed", "job", id, "err", err)
|
||||
status(train.Status{State: "failed", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
a.log.Info("trained an adapter", "job", id)
|
||||
status(train.Status{State: "done"})
|
||||
}
|
||||
+22
-1
@@ -40,10 +40,23 @@ func newAgentCmd() *cobra.Command {
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
log := logger()
|
||||
|
||||
cfg, err := config.LoadAgent(configPath)
|
||||
// The cluster's settings for this agent over its file, as the
|
||||
// control plane last sent them. Settings that no longer load
|
||||
// beside the file are set aside rather than keeping the agent
|
||||
// from starting: the control plane sends them again, and says
|
||||
// why they are refused.
|
||||
settings := agent.ReadSettings()
|
||||
cfg, err := config.LoadAgentWith(configPath, settings)
|
||||
if err != nil && settings != nil {
|
||||
log.Warn("the cluster's settings kept for this agent do not load beside its agent.yaml; starting without them",
|
||||
"err", err)
|
||||
settings = nil
|
||||
cfg, err = config.LoadAgent(configPath)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Settings = string(settings)
|
||||
if server != "" {
|
||||
cfg.Server = server
|
||||
}
|
||||
@@ -89,9 +102,17 @@ func newAgentCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.SetSettingsCheck(func(b []byte) error {
|
||||
_, err := config.LoadAgentWith(configPath, b)
|
||||
return err
|
||||
})
|
||||
|
||||
ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
go a.WatchFile(ctx, configPath, func() error {
|
||||
_, err := config.LoadAgentWith(configPath, agent.ReadSettings())
|
||||
return err
|
||||
})
|
||||
|
||||
log.Info("agent starting",
|
||||
"node", cfg.NodeID, "server", cfg.Server,
|
||||
|
||||
@@ -249,7 +249,14 @@ func pgEnv(dsn string) ([]string, error) {
|
||||
set("PGPASSWORD", pw)
|
||||
}
|
||||
set("PGDATABASE", strings.TrimPrefix(u.Path, "/"))
|
||||
set("PGSSLMODE", u.Query().Get("sslmode"))
|
||||
// The TLS settings go with the mode: verify-full without the cluster's
|
||||
// authority sent libpq looking for ~/.postgresql/root.crt, and the dump
|
||||
// failed on every control plane whose store is replicated.
|
||||
q := u.Query()
|
||||
set("PGSSLMODE", q.Get("sslmode"))
|
||||
set("PGSSLROOTCERT", q.Get("sslrootcert"))
|
||||
set("PGSSLCERT", q.Get("sslcert"))
|
||||
set("PGSSLKEY", q.Get("sslkey"))
|
||||
// A standby if there is one, else whichever answers — not the address's
|
||||
// read-write: a dump only reads, it need not load the primary, and one
|
||||
// that insists on the primary fails exactly while the cluster is failing
|
||||
|
||||
@@ -238,3 +238,17 @@ func TestAStoreOfSeveralHostsIsGivenToPgDumpAsLibpqLists(t *testing.T) {
|
||||
t.Error("an empty host in the list was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPgDumpIsGivenTheClusterAuthorityAlongWithVerifyFull(t *testing.T) {
|
||||
// Without it libpq looked for ~/.postgresql/root.crt and the dump failed.
|
||||
env, err := pgEnv("postgres://llmgw:p@10.0.0.149:5432,10.0.0.150:5432/llmgw?sslmode=verify-full&sslrootcert=%2Fetc%2Fllmgw%2Fpki%2Fca.crt&target_session_attrs=read-write")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := strings.Join(env, " ")
|
||||
for _, want := range []string{"PGSSLMODE=verify-full", "PGSSLROOTCERT=/etc/llmgw/pki/ca.crt"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("%s is not in %s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -241,6 +241,8 @@ var liveSettings = []string{
|
||||
"serving",
|
||||
// Sent to the nodes and this machine's firewall unit as they change.
|
||||
"firewall", "firewall_networks",
|
||||
// Sent to the nodes' agents as they change.
|
||||
"agents",
|
||||
// Read by server init, node add and backups, not by the server.
|
||||
"backup_recipients", "backup_copy_to",
|
||||
}
|
||||
|
||||
@@ -116,6 +116,11 @@ func (c *ctlClient) do(ctx context.Context, method, path string, body, out any)
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
// A stream, as it comes: NDJSON a long run writes line by line.
|
||||
if w, ok := out.(io.Writer); ok {
|
||||
_, err := io.Copy(w, resp.Body)
|
||||
return err
|
||||
}
|
||||
// An older control plane answers a path it does not know with its
|
||||
// dashboard: a page, which read as JSON said only "invalid character '<'".
|
||||
if strings.HasPrefix(resp.Header.Get("Content-Type"), "text/html") {
|
||||
@@ -180,6 +185,8 @@ func newCtlCmd() *cobra.Command {
|
||||
newCapacityCmd(client, &asJSON),
|
||||
newQuotaCmd(client, &asJSON),
|
||||
newGuardCmd(client, &asJSON),
|
||||
newKBCmd(client, &asJSON),
|
||||
newTrainCmd(client, &asJSON),
|
||||
newConfigCmd(client, &asJSON),
|
||||
newAuditCmd(client, &asJSON),
|
||||
newNotifyCmd(client, &asJSON),
|
||||
|
||||
+49
-21
@@ -7,12 +7,14 @@ import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/firewall"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
|
||||
)
|
||||
|
||||
// firewallStateFile is where a control plane writes what the cluster's
|
||||
@@ -20,11 +22,9 @@ import (
|
||||
// the server itself runs as llmgw and cannot change the ruleset.
|
||||
const firewallStateFile = "/var/lib/llmgw/firewall.json"
|
||||
|
||||
// firewallState is what is written there.
|
||||
type firewallState struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Networks []string `json:"networks,omitempty"`
|
||||
}
|
||||
// firewallState is what is written there: the same as the mirror is told
|
||||
// and keeps beside its models.
|
||||
type firewallState = mirror.FirewallState
|
||||
|
||||
// writeFirewallState records the settings' firewall for this machine's sync
|
||||
// unit. Without the state directory — a server not run by systemd — there
|
||||
@@ -46,25 +46,34 @@ func writeFirewallState(cfg config.Server) error {
|
||||
|
||||
// The units that apply it: a path unit watching the file, and the oneshot it
|
||||
// starts.
|
||||
const (
|
||||
firewallSyncPathUnit = "llmgw-firewall-sync.path"
|
||||
firewallSyncUnitFile = `[Unit]
|
||||
Description=llmgw firewall: apply the cluster's settings on this control plane
|
||||
const firewallSyncPathUnit = "llmgw-firewall-sync.path"
|
||||
|
||||
// firewallSyncUnits are the two units for a state file: on a control plane
|
||||
// the one its server writes, on a mirror the one the control plane has the
|
||||
// mirror write.
|
||||
func firewallSyncUnits(state string) (service, pathUnit string) {
|
||||
arg := ""
|
||||
if state != firewallStateFile {
|
||||
arg = " --state " + state
|
||||
}
|
||||
service = `[Unit]
|
||||
Description=llmgw firewall: apply the cluster's settings on this machine
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/llmgw firewall sync
|
||||
ExecStart=/usr/local/bin/llmgw firewall sync` + arg + `
|
||||
`
|
||||
firewallSyncPathFile = `[Unit]
|
||||
Description=llmgw firewall: follow the cluster's settings on this control plane
|
||||
pathUnit = `[Unit]
|
||||
Description=llmgw firewall: follow the cluster's settings on this machine
|
||||
|
||||
[Path]
|
||||
PathChanged=` + firewallStateFile + `
|
||||
PathChanged=` + state + `
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
)
|
||||
return service, pathUnit
|
||||
}
|
||||
|
||||
func newFirewallCmd() *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
@@ -76,6 +85,7 @@ func newFirewallCmd() *cobra.Command {
|
||||
"which " + firewallSyncPathUnit + " applies with 'llmgw firewall sync'.",
|
||||
}
|
||||
var install bool
|
||||
state := firewallStateFile
|
||||
sync := &cobra.Command{
|
||||
Use: "sync",
|
||||
Short: "Apply what the cluster's settings say of the firewall, here (root)",
|
||||
@@ -85,21 +95,21 @@ func newFirewallCmd() *cobra.Command {
|
||||
return errors.New("the firewall is changed as root: run it with sudo")
|
||||
}
|
||||
if install {
|
||||
if err := installFirewallSync(cmd.Context(), firewall.Local{}); err != nil {
|
||||
if err := installFirewallSync(cmd.Context(), firewall.Local{}, state); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(cmd.OutOrStdout(), firewallSyncPathUnit+" follows the cluster's settings here")
|
||||
if _, err := os.Stat(firewallStateFile); err != nil {
|
||||
return nil // the server writes it when it starts
|
||||
if _, err := os.Stat(state); err != nil {
|
||||
return nil // written when the settings are first told
|
||||
}
|
||||
}
|
||||
b, err := os.ReadFile(firewallStateFile)
|
||||
b, err := os.ReadFile(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var st firewallState
|
||||
if err := json.Unmarshal(b, &st); err != nil {
|
||||
return fmt.Errorf("%s: %w", firewallStateFile, err)
|
||||
return fmt.Errorf("%s: %w", state, err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 2*time.Minute)
|
||||
defer cancel()
|
||||
@@ -112,14 +122,17 @@ func newFirewallCmd() *cobra.Command {
|
||||
},
|
||||
}
|
||||
sync.Flags().BoolVar(&install, "install", false, "first install the units that run this whenever the settings change")
|
||||
sync.Flags().StringVar(&state, "state", firewallStateFile,
|
||||
"the file the settings are written to: a control plane's, or "+mirror.FirewallFile+" in a mirror's directory")
|
||||
c.AddCommand(sync)
|
||||
return c
|
||||
}
|
||||
|
||||
// installFirewallSync writes the sync units on a machine and has the path
|
||||
// unit follow the state file.
|
||||
func installFirewallSync(ctx context.Context, r firewall.Runner) error {
|
||||
body := firewallSyncUnitFile + "\x00" + firewallSyncPathFile
|
||||
func installFirewallSync(ctx context.Context, r firewall.Runner, state string) error {
|
||||
service, pathUnit := firewallSyncUnits(state)
|
||||
body := service + "\x00" + pathUnit
|
||||
out, err := r.Run(ctx, `set -e
|
||||
t=$(mktemp -d); trap 'rm -rf "$t"' EXIT
|
||||
cat > "$t/all"
|
||||
@@ -172,5 +185,20 @@ func syncFirewall(ctx context.Context, r firewall.Runner, st firewallState) (str
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
// The cluster's machines: a node on a network this one has not let in
|
||||
// yet, which the mirror's files are for.
|
||||
if len(st.Toward) > 0 {
|
||||
added, err := firewall.Admit(ctx, r, st.Toward)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(added) > 0 {
|
||||
shown := make([]string, len(added))
|
||||
for i, p := range added {
|
||||
shown[i] = p.String()
|
||||
}
|
||||
return "llmgw's firewall is on here, and now lets in " + strings.Join(shown, ", "), nil
|
||||
}
|
||||
}
|
||||
return "llmgw's firewall is on here", nil
|
||||
}
|
||||
@@ -2,8 +2,11 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/firewall"
|
||||
)
|
||||
|
||||
type scriptLog struct {
|
||||
@@ -49,3 +52,32 @@ func TestAControlPlaneFollowsTheFirewallSetting(t *testing.T) {
|
||||
t.Errorf("on: %q %v", msg, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMachineLetsInTheClusterMachinesItIsTold(t *testing.T) {
|
||||
// The rules hold 10.0.0.0/24; a node on 192.168.7.0/24 is let in, as
|
||||
// the route to it says, and one already in is not asked about.
|
||||
r := &scriptLog{reply: func(s string) string {
|
||||
switch {
|
||||
case strings.Contains(s, "echo loaded"):
|
||||
return "loaded\n"
|
||||
case strings.Contains(s, "cat /etc/llmgw/firewall.nft"):
|
||||
return firewallRulesFor("10.0.0.0/24")
|
||||
case strings.Contains(s, "ip -o route get 192.168.7.20"):
|
||||
return "192.168.7.20 dev eth1 src 192.168.7.1 uid 0\n2: eth1 inet 192.168.7.1/24 brd 192.168.7.255 scope global eth1\n"
|
||||
case strings.Contains(s, "nft -f"):
|
||||
return "changed\n"
|
||||
}
|
||||
return ""
|
||||
}}
|
||||
msg, err := syncFirewall(context.Background(), r, firewallState{Enabled: true, Toward: []string{"10.0.0.147", "192.168.7.20"}})
|
||||
if err != nil || !strings.Contains(msg, "192.168.7.0/24") {
|
||||
t.Fatalf("%q %v", msg, err)
|
||||
}
|
||||
if strings.Contains(strings.Join(r.scripts, "\n"), "route get 10.0.0.147") {
|
||||
t.Error("asked the route to a machine already let in")
|
||||
}
|
||||
}
|
||||
|
||||
func firewallRulesFor(nets string) string {
|
||||
return firewall.Script(firewall.Spec{Ports: []firewall.Port{firewall.MirrorFiles}, Allow: []netip.Prefix{netip.MustParsePrefix(nets)}})
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
// firewallStateKey is where, in the store, the firewall's state is kept
|
||||
// for the store's members to read.
|
||||
const firewallStateKey = "firewall"
|
||||
|
||||
// firewallPublisher tells the machines that cannot ask a control plane
|
||||
// what the cluster's settings say of their firewall: the mirror, over its
|
||||
// API, and the store's members, through the store they hold a copy of.
|
||||
// Nodes are told over their agents' streams and control planes by their
|
||||
// own servers. On the leader only, at start, at every change to the
|
||||
// firewall's settings, and every few minutes for nodes come and gone.
|
||||
type firewallPublisher struct {
|
||||
settings func() config.Server
|
||||
st *store.DB
|
||||
nodes func() []string
|
||||
leader func() bool
|
||||
log *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
mirror *mirror.Client
|
||||
kick chan struct{}
|
||||
}
|
||||
|
||||
func newFirewallPublisher(settings func() config.Server, st *store.DB, nodes func() []string,
|
||||
leader func() bool, log *slog.Logger) *firewallPublisher {
|
||||
return &firewallPublisher{settings: settings, st: st, nodes: nodes, leader: leader, log: log,
|
||||
kick: make(chan struct{}, 1)}
|
||||
}
|
||||
|
||||
// setMirror has the mirror told too.
|
||||
func (p *firewallPublisher) setMirror(m *mirror.Client) {
|
||||
p.mu.Lock()
|
||||
p.mirror = m
|
||||
p.mu.Unlock()
|
||||
p.Kick()
|
||||
}
|
||||
|
||||
// Kick has the state worked out and told again now.
|
||||
func (p *firewallPublisher) Kick() {
|
||||
select {
|
||||
case p.kick <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (p *firewallPublisher) run(ctx context.Context, every time.Duration) {
|
||||
t := time.NewTicker(every)
|
||||
defer t.Stop()
|
||||
for {
|
||||
p.once(ctx)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
case <-p.kick:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// state is what the settings say now, with the cluster's machines.
|
||||
func (p *firewallPublisher) state(ctx context.Context) mirror.FirewallState {
|
||||
cfg := p.settings()
|
||||
st := mirror.FirewallState{Enabled: cfg.Firewall.Enabled, Networks: slices.Clone(cfg.FirewallNetworks)}
|
||||
var toward []string
|
||||
if p.nodes != nil {
|
||||
toward = append(toward, p.nodes()...)
|
||||
}
|
||||
if p.st != nil {
|
||||
if cps, err := p.st.ControlPlanes(ctx); err == nil {
|
||||
for _, c := range cps {
|
||||
toward = append(toward, c.URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, t := range toward {
|
||||
if a, ok := hostAddr(t); ok {
|
||||
st.Toward = append(st.Toward, a)
|
||||
}
|
||||
}
|
||||
slices.Sort(st.Toward)
|
||||
st.Toward = slices.Compact(st.Toward)
|
||||
return st
|
||||
}
|
||||
|
||||
// hostAddr is the address in an address, host:port or URL; names are not
|
||||
// looked up here.
|
||||
func hostAddr(s string) (string, bool) {
|
||||
if u, err := url.Parse(s); err == nil && u.Host != "" {
|
||||
s = u.Hostname()
|
||||
} else if h, _, err := net.SplitHostPort(s); err == nil {
|
||||
s = h
|
||||
}
|
||||
a, err := netip.ParseAddr(s)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return a.Unmap().String(), true
|
||||
}
|
||||
|
||||
func (p *firewallPublisher) once(ctx context.Context) {
|
||||
if p.leader != nil && !p.leader() {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
st := p.state(ctx)
|
||||
p.mu.Lock()
|
||||
m := p.mirror
|
||||
p.mu.Unlock()
|
||||
if m != nil {
|
||||
changed, err := m.SetFirewall(ctx, st)
|
||||
switch {
|
||||
case err != nil:
|
||||
p.log.Warn("could not tell the mirror what the settings say of its firewall", "err", err)
|
||||
case changed:
|
||||
p.log.Info("the mirror's firewall follows the cluster's settings", "enabled", st.Enabled)
|
||||
}
|
||||
}
|
||||
if p.st != nil {
|
||||
b, _ := json.Marshal(st)
|
||||
changed, err := p.st.PutClusterState(ctx, firewallStateKey, string(b))
|
||||
switch {
|
||||
case err != nil:
|
||||
p.log.Warn("could not record the firewall's state for the store's members", "err", err)
|
||||
case changed:
|
||||
p.log.Info("the store's members' firewall follows the cluster's settings", "enabled", st.Enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf"
|
||||
)
|
||||
|
||||
func TestTheLeaderTellsTheMirrorAndTheStoreWhatTheFirewallSettingsSay(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, err := store.OpenSQLite(ctx, filepath.Join(t.TempDir(), "s.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
if err := st.PutControlPlane(ctx, store.ControlPlane{URL: "https://10.0.0.150:7443", Name: "ctl02"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := t.TempDir()
|
||||
srv := httptest.NewServer(mirror.NewAPI(mirror.NewManager(root, slog.New(slog.DiscardHandler)), "http://m:8081").Handler())
|
||||
defer srv.Close()
|
||||
mc, _ := mirror.NewClient(srv.URL, tlsconf.Config{}, 0)
|
||||
|
||||
cfg := config.Server{Firewall: config.FirewallSettings{Enabled: false}, FirewallNetworks: []string{"192.168.5.0/24"}}
|
||||
leader := false
|
||||
p := newFirewallPublisher(func() config.Server { return cfg }, st,
|
||||
func() []string { return []string{"10.0.0.147", "", "10.0.0.147", "node-by-name"} },
|
||||
func() bool { return leader }, slog.New(slog.DiscardHandler))
|
||||
p.setMirror(mc)
|
||||
|
||||
p.once(ctx)
|
||||
if _, err := st.ClusterState(ctx, firewallStateKey); err == nil {
|
||||
t.Error("a standby told the store")
|
||||
}
|
||||
leader = true
|
||||
p.once(ctx)
|
||||
var got mirror.FirewallState
|
||||
v, err := st.ClusterState(ctx, firewallStateKey)
|
||||
if err != nil || json.Unmarshal([]byte(v), &got) != nil {
|
||||
t.Fatalf("the store holds %q %v", v, err)
|
||||
}
|
||||
if got.Enabled || got.Networks[0] != "192.168.5.0/24" || len(got.Toward) != 2 ||
|
||||
got.Toward[0] != "10.0.0.147" || got.Toward[1] != "10.0.0.150" {
|
||||
t.Errorf("state %+v", got)
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(root, mirror.FirewallFile))
|
||||
if err != nil || string(b) != v {
|
||||
t.Errorf("the mirror holds %s, the store %s", b, v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
)
|
||||
|
||||
func newKBCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: "kb",
|
||||
Short: "Knowledge bases: collections of documents requests are answered from",
|
||||
Long: "A collection belongs to an organization, team, project or person, and is\n" +
|
||||
"embedded by one model of the catalog. People put documents into it from\n" +
|
||||
"their own machines with llmgw-kb (its own download, for Windows, macOS and\n" +
|
||||
"Linux), which sends text and vectors, never the files.\n\n" +
|
||||
"Who may read or add to a collection: its owner and everything below it,\n" +
|
||||
"and whoever it is granted to (grant), never outside its organization.\n" +
|
||||
"Targets are written org:Acme, team:Acme/hr, project:Acme/hr/docs (or with\n" +
|
||||
"IDs), key:KEY-ID or user:USER-ID. A collection is named by its ID, its name\n" +
|
||||
"when that is unique, or ORG/NAME.\n\n" +
|
||||
"Needs the PostgreSQL store with pgvector; `llmgw ctl kb list` says why not.",
|
||||
}
|
||||
c.AddCommand(&cobra.Command{
|
||||
Use: "list",
|
||||
Short: "Every collection: owner, model, documents and grants",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
var v adminapi.KBView
|
||||
if err := client().get(cmd.Context(), "/api/v1/kb", &v); err != nil {
|
||||
return err
|
||||
}
|
||||
if *asJSON {
|
||||
return json.NewEncoder(cmd.OutOrStdout()).Encode(v)
|
||||
}
|
||||
out := cmd.OutOrStdout()
|
||||
if !v.Available {
|
||||
fmt.Fprintln(out, "Knowledge bases are off: "+v.Reason)
|
||||
return nil
|
||||
}
|
||||
if len(v.Collections) == 0 {
|
||||
fmt.Fprintln(out, "No collections yet (llmgw ctl kb create).")
|
||||
return nil
|
||||
}
|
||||
w := tab()
|
||||
fmt.Fprintln(w, "COLLECTION\tOWNER\tMODEL\tDOCUMENTS\tCHUNKS\tGRANTED TO")
|
||||
for _, c := range v.Collections {
|
||||
var grants []string
|
||||
for _, g := range c.Grants {
|
||||
grants = append(grants, or(g.Name, g.Target)+" ("+g.Perm+")")
|
||||
}
|
||||
fmt.Fprintf(w, "%s/%s\t%s\t%s (%d)\t%d\t%d\t%s\n", or(c.OrgName, c.Org), c.Name, or(c.OwnerName, c.Owner),
|
||||
c.EmbedModel, c.Dims, c.Documents, c.Chunks, strings.Join(grants, ", "))
|
||||
}
|
||||
if err := w.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(v.Defaults) > 0 {
|
||||
fmt.Fprintln(out, "\nAnswered from by default, when a request names no collection:")
|
||||
w = tab()
|
||||
for _, d := range v.Defaults {
|
||||
fmt.Fprintf(w, " %s\t%s\n", or(d.Name, d.Target), strings.Join(d.Collections, ", "))
|
||||
}
|
||||
return w.Flush()
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
var org string
|
||||
var dims int
|
||||
create := &cobra.Command{
|
||||
Use: "create OWNER NAME MODEL",
|
||||
Short: "Make a collection owned by an organization, team, project or person",
|
||||
Long: "MODEL is a catalog model with capabilities.embeddings. Its dimension is asked\n" +
|
||||
"of it, so it has to be able to serve now; --dims gives it instead.",
|
||||
Example: " llmgw ctl kb create team:Acme/hr policies bge-m3\n" +
|
||||
" llmgw ctl kb create user:USER-ID notes bge-m3 --org Acme",
|
||||
Args: cobra.ExactArgs(3),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
body, _ := json.Marshal(adminapi.KBCreateRequest{Owner: args[0], Name: args[1], Model: args[2], Org: org, Dims: dims})
|
||||
var v adminapi.KBCollectionView
|
||||
if err := client().do(cmd.Context(), "POST", "/api/v1/kb/collections", json.RawMessage(body), &v); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "collection %s (%s), embedded by %s in %d dimensions\n", v.Name, v.ID, v.EmbedModel, v.Dims)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "people with access fill it with: llmgw-kb sync %s FOLDER\n", v.Name)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
create.Flags().StringVar(&org, "org", "", "the organization of a person's collection")
|
||||
create.Flags().IntVar(&dims, "dims", 0, "the model's embedding dimension, instead of asking it")
|
||||
c.AddCommand(create)
|
||||
|
||||
c.AddCommand(&cobra.Command{
|
||||
Use: "delete COLLECTION",
|
||||
Short: "Remove a collection, its documents and its grants",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := client().do(cmd.Context(), "DELETE", "/api/v1/kb/collections/"+url.PathEscape(args[0]), nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "collection %s removed\n", args[0])
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
for _, verb := range []struct {
|
||||
use, short, method string
|
||||
perm bool
|
||||
}{
|
||||
{"grant", "Let a scope and everything below it read a collection, or with --write add to it", "POST", true},
|
||||
{"revoke", "Take a grant away", "DELETE", false},
|
||||
} {
|
||||
var write bool
|
||||
cmd := &cobra.Command{
|
||||
Use: verb.use + " COLLECTION TARGET",
|
||||
Short: verb.short,
|
||||
Example: " llmgw ctl kb " + verb.use + " Acme/policies project:Acme/sales/crm",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
req := adminapi.KBGrantRequest{Collection: args[0], Target: args[1], Perm: "read"}
|
||||
if write {
|
||||
req.Perm = "write"
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
if err := client().do(cmd.Context(), verb.method, "/api/v1/kb/grants", json.RawMessage(body), nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if verb.perm {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s may %s %s\n", args[1], req.Perm, args[0])
|
||||
} else {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s no longer granted %s\n", args[1], args[0])
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
if verb.perm {
|
||||
cmd.Flags().BoolVar(&write, "write", false, "add and remove documents too")
|
||||
}
|
||||
c.AddCommand(cmd)
|
||||
}
|
||||
|
||||
c.AddCommand(&cobra.Command{
|
||||
Use: "docs COLLECTION",
|
||||
Short: "A collection's documents",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var docs []adminapi.KBDocumentView
|
||||
if err := client().get(cmd.Context(), "/api/v1/kb/collections/"+url.PathEscape(args[0])+"/documents", &docs); err != nil {
|
||||
return err
|
||||
}
|
||||
if *asJSON {
|
||||
return json.NewEncoder(cmd.OutOrStdout()).Encode(docs)
|
||||
}
|
||||
w := tab()
|
||||
fmt.Fprintln(w, "DOCUMENT\tCHUNKS\tSIZE\tADDED\tBY")
|
||||
for _, d := range docs {
|
||||
fmt.Fprintf(w, "%s\t%d\t%s\t%s\t%s\n", d.Name, d.Chunks, humanBytes(d.Bytes), d.AddedAt.Local().Format("2006-01-02 15:04"), d.AddedBy)
|
||||
}
|
||||
return w.Flush()
|
||||
},
|
||||
})
|
||||
c.AddCommand(&cobra.Command{
|
||||
Use: "rm COLLECTION DOCUMENT",
|
||||
Short: "Remove one document from a collection",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
path := "/api/v1/kb/collections/" + url.PathEscape(args[0]) + "/documents?name=" + url.QueryEscape(args[1])
|
||||
if err := client().do(cmd.Context(), "DELETE", path, nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s removed from %s\n", args[1], args[0])
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
var clear bool
|
||||
def := &cobra.Command{
|
||||
Use: "default TARGET [COLLECTION...]",
|
||||
Short: "Answer a part of the tree's chat requests from collections, when they name none",
|
||||
Long: "Requests from TARGET and everything below it are then answered from these\n" +
|
||||
"collections without naming them (X-Llmgw-Knowledge). The narrowest default\n" +
|
||||
"wins: a key's over its project's, a project's over its team's. A request's own\n" +
|
||||
"header overrides it, and X-Llmgw-Knowledge: none turns it off. A collection a\n" +
|
||||
"caller may not read is left out for them, and a search that fails answers\n" +
|
||||
"without passages rather than refusing. In force within 30 seconds.",
|
||||
Example: " llmgw ctl kb default team:Acme/hr Acme/policies Acme/forms\n" +
|
||||
" llmgw ctl kb default team:Acme/hr --clear",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if clear != (len(args) == 1) {
|
||||
return fmt.Errorf("name collections, or --clear")
|
||||
}
|
||||
body, _ := json.Marshal(adminapi.KBDefaultRequest{Target: args[0], Collections: args[1:]})
|
||||
if err := client().do(cmd.Context(), "POST", "/api/v1/kb/defaults", json.RawMessage(body), nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if clear {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s answers from no collection by default\n", args[0])
|
||||
} else {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s answers from %s by default\n", args[0], strings.Join(args[1:], ", "))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
def.Flags().BoolVar(&clear, "clear", false, "no default any more")
|
||||
c.AddCommand(def)
|
||||
|
||||
var dsModel, dsOut string
|
||||
var dsPer, dsMax int
|
||||
dataset := &cobra.Command{
|
||||
Use: "dataset COLLECTION --model MODEL",
|
||||
Short: "Write training data from a collection: questions and answers, for an adapter",
|
||||
Long: "A chat model of the cluster reads each chunk of the collection and writes\n" +
|
||||
"questions a person might ask of it, with the answers it holds, one JSON object\n" +
|
||||
"a line ({\"q\", \"a\", \"doc\", \"ord\"}). Read it through and correct it before\n" +
|
||||
"training on it (llmgw ctl train): what the model got wrong, an adapter learns.\n\n" +
|
||||
"An adapter learns how answers sound — the organisation's words, format and\n" +
|
||||
"tone — far better than what they say; the facts stay the knowledge base's.",
|
||||
Example: " llmgw ctl kb dataset Acme/policies --model qwen2.5-coder-14b-instruct --out policies.jsonl",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
w := cmd.OutOrStdout()
|
||||
if dsOut != "" {
|
||||
f, err := os.Create(dsOut)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
w = f
|
||||
}
|
||||
lw := &datasetWriter{pairs: w, problems: cmd.ErrOrStderr()}
|
||||
body, _ := json.Marshal(adminapi.KBDatasetRequest{Model: dsModel, PerChunk: dsPer, MaxChunks: dsMax})
|
||||
err := client().patient(6*time.Hour).do(cmd.Context(), "POST",
|
||||
"/api/v1/kb/collections/"+url.PathEscape(args[0])+"/dataset", json.RawMessage(body), lw)
|
||||
lw.flush()
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "%d pairs from %d chunks; %d chunks gave none\n", lw.n, len(lw.chunks), lw.failed)
|
||||
return err
|
||||
},
|
||||
}
|
||||
dataset.Flags().StringVar(&dsModel, "model", "", "the chat model that writes the questions and answers")
|
||||
dataset.Flags().StringVar(&dsOut, "out", "", "the file to write (default: standard output)")
|
||||
dataset.Flags().IntVar(&dsPer, "per-chunk", 8, "pairs from each chunk")
|
||||
dataset.Flags().IntVar(&dsMax, "max-chunks", 500, "the most chunks read")
|
||||
_ = dataset.MarkFlagRequired("model")
|
||||
c.AddCommand(dataset)
|
||||
|
||||
var k int
|
||||
search := &cobra.Command{
|
||||
Use: "search COLLECTION QUERY...",
|
||||
Short: "Try a search of a collection, as an administrator (no tenancy applies)",
|
||||
Args: cobra.MinimumNArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
body, _ := json.Marshal(adminapi.KBSearchRequest{Collection: args[0], Query: strings.Join(args[1:], " "), K: k})
|
||||
var hits []adminapi.KBHitView
|
||||
if err := client().do(cmd.Context(), "POST", "/api/v1/kb/search", json.RawMessage(body), &hits); err != nil {
|
||||
return err
|
||||
}
|
||||
if *asJSON {
|
||||
return json.NewEncoder(cmd.OutOrStdout()).Encode(hits)
|
||||
}
|
||||
out := cmd.OutOrStdout()
|
||||
if len(hits) == 0 {
|
||||
fmt.Fprintln(out, "Nothing found.")
|
||||
}
|
||||
for i, h := range hits {
|
||||
fmt.Fprintf(out, "%d. %s #%d\n%s\n\n", i+1, h.Document, h.Ord, indent(strings.TrimSpace(h.Text), " "))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
search.Flags().IntVarP(&k, "k", "k", 5, "how many chunks")
|
||||
c.AddCommand(search)
|
||||
return c
|
||||
}
|
||||
|
||||
// datasetWriter splits the dataset stream: pairs to the file, problems to
|
||||
// the terminal, counted as they pass.
|
||||
type datasetWriter struct {
|
||||
pairs, problems io.Writer
|
||||
buf []byte
|
||||
n, failed int
|
||||
chunks map[string]bool
|
||||
}
|
||||
|
||||
func (d *datasetWriter) Write(p []byte) (int, error) {
|
||||
d.buf = append(d.buf, p...)
|
||||
for {
|
||||
i := bytes.IndexByte(d.buf, '\n')
|
||||
if i < 0 {
|
||||
return len(p), nil
|
||||
}
|
||||
d.line(d.buf[:i])
|
||||
d.buf = d.buf[i+1:]
|
||||
}
|
||||
}
|
||||
|
||||
func (d *datasetWriter) flush() {
|
||||
if len(bytes.TrimSpace(d.buf)) > 0 {
|
||||
d.line(d.buf)
|
||||
}
|
||||
d.buf = nil
|
||||
}
|
||||
|
||||
func (d *datasetWriter) line(b []byte) {
|
||||
var l adminapi.KBDatasetLine
|
||||
if json.Unmarshal(b, &l) != nil {
|
||||
return
|
||||
}
|
||||
if d.chunks == nil {
|
||||
d.chunks = map[string]bool{}
|
||||
}
|
||||
d.chunks[fmt.Sprintf("%s#%d", l.Doc, l.Ord)] = true
|
||||
if l.Error != "" {
|
||||
d.failed++
|
||||
fmt.Fprintf(d.problems, " %s #%d: %s\n", l.Doc, l.Ord, l.Error)
|
||||
return
|
||||
}
|
||||
d.n++
|
||||
out, _ := json.Marshal(l)
|
||||
fmt.Fprintln(d.pairs, string(out))
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -65,6 +66,14 @@ Safe to run again: it changes only what differs.`,
|
||||
}
|
||||
if !noFirewall {
|
||||
mirrorFirewall(cmd.Context(), cmd.OutOrStdout(), controlPlanes)
|
||||
// And after, as the cluster's settings say: the control plane
|
||||
// tells the mirror, which writes it beside its models.
|
||||
state := filepath.Join(*dir, mirror.FirewallFile)
|
||||
if err := installFirewallSync(cmd.Context(), firewall.Local{}, state); err != nil {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), " !! firewall: %v; the cluster's settings will not change it here\n", err)
|
||||
} else {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " ok firewall: follows the cluster's settings ("+firewallSyncPathUnit+")")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -48,6 +49,7 @@ import (
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/fleetmetrics"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelsvc"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/reconciler"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/train"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/hf"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/kvevents"
|
||||
@@ -231,6 +233,9 @@ func newServerCmd() *cobra.Command {
|
||||
var agentHandler http.Handler
|
||||
var agentPath string
|
||||
var admin *control.Controller
|
||||
// What the settings say of the firewall, for the mirror and the
|
||||
// store's members; nil without a control plane.
|
||||
var fwPub *firewallPublisher
|
||||
updateMetrics := &modelserve.UpdateCollector{}
|
||||
// The store's cluster, when it is one; read by the metrics and
|
||||
// by `ctl status`.
|
||||
@@ -262,9 +267,31 @@ func newServerCmd() *cobra.Command {
|
||||
return &llmgwv1.Firewall{Enabled: c.Firewall.Enabled, Networks: c.FirewallNetworks,
|
||||
EnginePortsFrom: int32(from), EnginePortsTo: int32(to)} //nolint:gosec // ports
|
||||
})
|
||||
// What each node's agent runs on, as the settings say now.
|
||||
svc.SetAgentSettings(func(node string) string {
|
||||
b, err := config.Marshal(current.Load().Agents.For(node))
|
||||
if err != nil {
|
||||
log.Warn("could not write a node's agent settings", "node", node, "err", err)
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
})
|
||||
ctl.Reconciler().SetNotifier(notifier)
|
||||
admin = ctl
|
||||
agentPath, agentHandler = svc.Handler()
|
||||
fwPub = newFirewallPublisher(func() config.Server { return *current.Load() }, rt.Store(),
|
||||
func() []string {
|
||||
var out []string
|
||||
for _, n := range ctl.Inventory().Nodes() {
|
||||
out = append(out, n.MgmtAddr)
|
||||
}
|
||||
return out
|
||||
},
|
||||
func() bool { return ctl.Leadership() == nil || ctl.Leadership().IsLeader() }, log)
|
||||
go fwPub.run(ctx, 5*time.Minute)
|
||||
if l := ctl.Leadership(); l != nil {
|
||||
l.OnLead(fwPub.Kick)
|
||||
}
|
||||
|
||||
// Before AdminHandler, which is where the registry is built —
|
||||
// registering after it silently publishes nothing, which is
|
||||
@@ -374,10 +401,22 @@ func newServerCmd() *cobra.Command {
|
||||
api.SetAgentRestarter(agentSvc.RestartAgents)
|
||||
}
|
||||
api.SetAudit(st)
|
||||
// Knowledge bases, with the cluster's own models to embed a
|
||||
// probe (a collection's dimension) and an operator's search.
|
||||
api.SetKB(&adminapi.KBAdmin{Store: st, Embed: embedder(rt.Replay)})
|
||||
// LoRA adapters trained on the nodes.
|
||||
if agentSvc != nil {
|
||||
trainer := train.New(st, agentSvc, log)
|
||||
agentSvc.SetTrain(trainer)
|
||||
go trainer.Run(ctx)
|
||||
api.SetTrain(&adminapi.TrainAdmin{Store: st, Service: trainer})
|
||||
}
|
||||
// The cluster's settings, each change checked against this
|
||||
// machine's own keys before it is kept.
|
||||
api.SetSettings(&adminapi.Settings{Store: st, LocalKeys: config.LocalKeys(),
|
||||
Check: func(body []byte) error { return checkClusterSettings(configPath, body) }})
|
||||
// And the same document on the portal, for administrators.
|
||||
rt.SetSettingsCheck(func(body []byte) error { return checkClusterSettings(configPath, body) }, config.LocalKeys())
|
||||
}
|
||||
// Catalog changes need somewhere to persist, so this is wired
|
||||
// only when there is a store. Without one, the endpoints say so
|
||||
@@ -434,6 +473,7 @@ func newServerCmd() *cobra.Command {
|
||||
api.SetUsage(st)
|
||||
// Capacity quotas, set and read over this API.
|
||||
api.SetQuotas(st)
|
||||
api.SetTenancy(st)
|
||||
}
|
||||
api.Register(adminMux)
|
||||
|
||||
@@ -495,6 +535,16 @@ func newServerCmd() *cobra.Command {
|
||||
return fmt.Errorf("the model mirror: %w", err)
|
||||
}
|
||||
rt.SetMirror(mc)
|
||||
if fwPub != nil {
|
||||
fwPub.setMirror(mc)
|
||||
}
|
||||
// Adapters trained on the nodes are kept there too; sending one
|
||||
// takes longer than the mirror's usual answers.
|
||||
if adminAPI != nil {
|
||||
if up, err := mirror.NewClient(cfg.Mirror.API, cfg.Mirror.TLS, 10*time.Minute); err == nil {
|
||||
adminAPI.SetTrainMirror(up)
|
||||
}
|
||||
}
|
||||
// Browsing the registry is what turns "paste a resolve URL"
|
||||
// into "find the model". It needs a way out to the internet
|
||||
// from this machine, which the mirror's own fetching does not.
|
||||
@@ -712,6 +762,13 @@ func newServerCmd() *cobra.Command {
|
||||
if agentSvc != nil {
|
||||
agentSvc.BroadcastFirewall()
|
||||
}
|
||||
if fwPub != nil {
|
||||
fwPub.Kick()
|
||||
}
|
||||
}
|
||||
if slices.ContainsFunc(changed, func(c string) bool { return strings.HasPrefix(c, "agents") }) && agentSvc != nil {
|
||||
// A moment apart per node, so off this goroutine.
|
||||
go agentSvc.BroadcastAgentSettings()
|
||||
}
|
||||
if slices.ContainsFunc(changed, func(c string) bool { return strings.HasPrefix(c, "guard") }) {
|
||||
if gate == nil {
|
||||
@@ -1183,3 +1240,40 @@ func federationID(ctx context.Context, st *storepkg.DB) (string, error) {
|
||||
// more of a secret than needed.
|
||||
return secret[:16], nil
|
||||
}
|
||||
|
||||
// embedder has a model embed one text through the gateway itself, the way a
|
||||
// kept request is replayed: for the administrator's knowledge-base
|
||||
// endpoints, not for anybody's traffic.
|
||||
func embedder(replay func(ctx context.Context, path, model string, body []byte) (int, []byte, time.Duration, error)) func(context.Context, string, string) ([]float32, error) {
|
||||
return func(ctx context.Context, model, text string) ([]float32, error) {
|
||||
body, _ := json.Marshal(map[string]any{"model": model, "input": []string{text}})
|
||||
status, out, _, err := replay(ctx, "/v1/embeddings", model, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
msg := strings.TrimSpace(string(out))
|
||||
var e struct {
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(out, &e) == nil && e.Error.Message != "" {
|
||||
msg = e.Error.Message
|
||||
}
|
||||
return nil, fmt.Errorf("%d: %s", status, msg)
|
||||
}
|
||||
var res struct {
|
||||
Data []struct {
|
||||
Embedding []float32 `json:"embedding"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(res.Data) != 1 || len(res.Data[0].Embedding) == 0 {
|
||||
return nil, fmt.Errorf("%s answered no embedding", model)
|
||||
}
|
||||
return res.Data[0].Embedding, nil
|
||||
}
|
||||
}
|
||||
@@ -160,7 +160,7 @@ func (sa *serverAdd) firewall(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
// What applies the cluster's firewall setting there when it changes.
|
||||
if err := installFirewallSync(ctx, sa.remote); err != nil {
|
||||
if err := installFirewallSync(ctx, sa.remote, firewallStateFile); err != nil {
|
||||
fmt.Fprintf(sa.out, " !! firewall: %v\n", err)
|
||||
}
|
||||
if !cfg.Firewall.Enabled {
|
||||
|
||||
@@ -93,6 +93,11 @@ func (in *serverInit) storeFirewall(ctx context.Context, members []pgha.Node, se
|
||||
} else {
|
||||
in.kept("firewall on %s", m.Name)
|
||||
}
|
||||
// And after, as the cluster's settings say, read from the
|
||||
// member's own copy of the store.
|
||||
if err := firewall.InstallFollower(ctx, m.Remote); err != nil {
|
||||
fmt.Fprintf(in.out, " !! firewall on %s: %v; the cluster's settings will not change it\n", m.Name, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -135,5 +140,5 @@ func loopbackListen(listen string) bool {
|
||||
// ensureFirewallSync installs the units that apply the cluster's firewall
|
||||
// setting on this control plane.
|
||||
func (in *serverInit) ensureFirewallSync(ctx context.Context) error {
|
||||
return installFirewallSync(ctx, firewall.Local{})
|
||||
return installFirewallSync(ctx, firewall.Local{}, firewallStateFile)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/provision"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/pgha"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
@@ -348,15 +349,23 @@ func (in *serverInit) setupPostgres(ctx context.Context) (string, error) {
|
||||
}
|
||||
|
||||
if installed {
|
||||
// Its defaults assume it owns the machine. A gateway swapped out to
|
||||
// give the database cache is worse than a smaller cache, and a store
|
||||
// whose working set is megabytes needs little. Only a PostgreSQL init
|
||||
// installed itself: one that was already here may serve something
|
||||
// else that needs its connections.
|
||||
for _, q := range []string{
|
||||
"ALTER SYSTEM SET shared_buffers = '96MB'",
|
||||
"ALTER SYSTEM SET max_connections = 40",
|
||||
} {
|
||||
// Sized to the machine's memory, as one it shares with the gateway:
|
||||
// a gateway swapped out to give the database cache is worse than a
|
||||
// smaller cache, but a knowledge base's vector index is searched
|
||||
// well only from memory. Only a PostgreSQL init installed itself:
|
||||
// one that was already here may serve something else that needs its
|
||||
// connections.
|
||||
meminfo, _ := os.ReadFile("/proc/meminfo")
|
||||
total, ok := pgha.MemTotal(string(meminfo))
|
||||
if !ok {
|
||||
total = 1 << 30
|
||||
}
|
||||
mem := pgha.MemorySettings(total, true)
|
||||
qs := []string{"ALTER SYSTEM SET max_connections = 40"}
|
||||
for _, k := range []string{"shared_buffers", "effective_cache_size", "maintenance_work_mem", "work_mem"} {
|
||||
qs = append(qs, "ALTER SYSTEM SET "+k+" = '"+mem[k]+"'")
|
||||
}
|
||||
for _, q := range qs {
|
||||
if _, err := in.psql(ctx, q); err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -367,9 +376,12 @@ func (in *serverInit) setupPostgres(ctx context.Context) (string, error) {
|
||||
if err := in.waitForPostgres(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
in.done("PostgreSQL sized for a machine it shares (shared_buffers 96MB, 40 connections)")
|
||||
in.done("PostgreSQL sized for a machine it shares, with %d MB of memory (shared_buffers %s, work_mem %s, 40 connections)",
|
||||
total>>20, mem["shared_buffers"], mem["work_mem"])
|
||||
}
|
||||
|
||||
in.setupPgvector(ctx, discovery.DiscoverHost())
|
||||
|
||||
// Proof the address works, before anything is moved into it.
|
||||
address := "@" + in.dsnPath()
|
||||
s, err := store.Open(ctx, address)
|
||||
@@ -482,8 +494,17 @@ func lastLines(b []byte, n int) string {
|
||||
|
||||
// psql runs one statement as the postgres superuser, over the local socket.
|
||||
func (in *serverInit) psql(ctx context.Context, query string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "runuser", "-u", "postgres", "--",
|
||||
in.pg.tool("psql"), "-v", "ON_ERROR_STOP=1", "-qAtX", "-c", query)
|
||||
return in.psqlIn(ctx, "", query)
|
||||
}
|
||||
|
||||
// psqlIn runs a statement as postgres in one database, or in postgres's
|
||||
// default one when db is "".
|
||||
func (in *serverInit) psqlIn(ctx context.Context, db, query string) (string, error) {
|
||||
args := []string{"-v", "ON_ERROR_STOP=1", "-qAtX", "-c", query}
|
||||
if db != "" {
|
||||
args = append(args, "-d", db)
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "runuser", append([]string{"-u", "postgres", "--", in.pg.tool("psql")}, args...)...)
|
||||
// postgres cannot enter root's home, and psql says so on every call.
|
||||
cmd.Dir = "/"
|
||||
out, err := cmd.CombinedOutput()
|
||||
@@ -538,3 +559,50 @@ func (in *serverInit) stopService(ctx context.Context) (bool, error) {
|
||||
in.done("llmgw-server stopped while its store moves")
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// pgvectorPackages are the package names of pgvector for a PostgreSQL major
|
||||
// version: Debian's and Ubuntu's (and PGDG's apt repository) name it after the
|
||||
// server, PGDG's rpm repository after the version alone.
|
||||
func pgvectorPackages(major int) map[string][]string {
|
||||
return map[string][]string{
|
||||
"deb": {fmt.Sprintf("postgresql-%d-pgvector", major)},
|
||||
"rpm": {fmt.Sprintf("pgvector_%d", major)},
|
||||
}
|
||||
}
|
||||
|
||||
// setupPgvector installs pgvector and enables it in llmgw's database, for
|
||||
// knowledge bases. Not a reason to stop: without it everything else works,
|
||||
// and the store says why knowledge bases are off when somebody asks for one.
|
||||
func (in *serverInit) setupPgvector(ctx context.Context, h discovery.Host) {
|
||||
warn := func(format string, a ...any) {
|
||||
fmt.Fprintf(in.out, " !! pgvector: "+format+"; knowledge bases stay off\n", a...)
|
||||
}
|
||||
if on, err := in.psqlIn(ctx, pgDatabase, "SELECT 1 FROM pg_extension WHERE extname = 'vector'"); err == nil && on == "1" {
|
||||
in.kept("pgvector in database %s", pgDatabase)
|
||||
return
|
||||
}
|
||||
have, err := in.psqlBool(ctx, "SELECT 1 FROM pg_available_extensions WHERE name = 'vector'")
|
||||
if err != nil {
|
||||
warn("%v", err)
|
||||
return
|
||||
}
|
||||
if !have {
|
||||
num, err := in.psql(ctx, "SHOW server_version_num")
|
||||
n, convErr := strconv.Atoi(num)
|
||||
if err != nil || convErr != nil || h.Family() == "" {
|
||||
warn("cannot tell which package to install (version %q, %s)", num, h.OSID)
|
||||
return
|
||||
}
|
||||
pkgs := pgvectorPackages(n / 10000)
|
||||
pi := &provision.Installer{Host: h, Log: slog.Default()}
|
||||
if err := pi.InstallPackages(ctx, pkgs); err != nil {
|
||||
warn("installing %s: %v", strings.Join(pkgs[h.Family()], " "), err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if _, err := in.psqlIn(ctx, pgDatabase, "CREATE EXTENSION IF NOT EXISTS vector"); err != nil {
|
||||
warn("%v", err)
|
||||
return
|
||||
}
|
||||
in.done("pgvector enabled in database %s", pgDatabase)
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
func newTrainCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: "train",
|
||||
Short: "Train LoRA adapters on the nodes, on question/answer pairs",
|
||||
Long: "A node trains an adapter for a catalog model on pairs (llmgw ctl kb dataset\n" +
|
||||
"writes them from a knowledge base; read them through first). It trains with\n" +
|
||||
"its installed vLLM's torch and a small overlay, as the engines' user, on a card\n" +
|
||||
"with --gpu-memory free — beside an engine, if it leaves that much. What it\n" +
|
||||
"makes is kept by the control plane: llmgw ctl train download, or publish it\n" +
|
||||
"as the model's adapter.\n\n" +
|
||||
"An adapter learns how answers sound — words, format, tone — far better than\n" +
|
||||
"what they say. Facts belong to the knowledge base.",
|
||||
}
|
||||
|
||||
var model, base, rev, node, data, owner string
|
||||
var public bool
|
||||
var gpuMem int
|
||||
var epochs, rank int
|
||||
var lr float64
|
||||
var noGGUF bool
|
||||
start := &cobra.Command{
|
||||
Use: "start NAME --model MODEL --base-repo OWNER/NAME --node NODE --data FILE",
|
||||
Short: "Queue an adapter's training on a node",
|
||||
Example: " llmgw ctl train start policies-tone --model qwen2.5-0.5b-instruct \\\n" +
|
||||
" --base-repo Qwen/Qwen2.5-0.5B-Instruct --node cuda01 --gpu-memory 2300 --data policies.jsonl",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
b, err := os.ReadFile(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req := adminapi.TrainRequest{Name: args[0], Model: model, BaseRepo: base, BaseRevision: rev, Node: node,
|
||||
GPUMemoryMiB: gpuMem, Data: string(b), Params: map[string]any{}, Owner: owner, Public: public}
|
||||
if cmd.Flags().Changed("epochs") {
|
||||
req.Params["epochs"] = epochs
|
||||
}
|
||||
if cmd.Flags().Changed("rank") {
|
||||
req.Params["rank"] = rank
|
||||
}
|
||||
if cmd.Flags().Changed("lr") {
|
||||
req.Params["lr"] = lr
|
||||
}
|
||||
if noGGUF {
|
||||
f := false
|
||||
req.GGUF = &f
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
var v adminapi.TrainJobView
|
||||
if err := client().patient(2*time.Minute).do(cmd.Context(), "POST", "/api/v1/train/jobs", json.RawMessage(body), &v); err != nil {
|
||||
return err
|
||||
}
|
||||
who := "every caller of " + v.Model
|
||||
if v.Owner != "" {
|
||||
who = v.Owner + " only"
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "queued %s: %s for %s on %s, %d pairs; it will be seen by %s\n",
|
||||
v.ID, v.Name, v.Model, v.Node, v.Pairs, who)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "follow it with: llmgw ctl train show %s --follow\n", v.ID)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
f := start.Flags()
|
||||
f.StringVar(&model, "model", "", "the catalog model the adapter is for")
|
||||
f.StringVar(&base, "base-repo", "", "the weights to train on, as safetensors on Hugging Face (owner/name)")
|
||||
f.StringVar(&rev, "base-revision", "", "a revision of them (default: the newest)")
|
||||
f.StringVar(&node, "node", "", "the node that trains")
|
||||
f.IntVar(&gpuMem, "gpu-memory", 4096, "MiB of a card the job needs free")
|
||||
f.StringVar(&data, "data", "", "the pairs, NDJSON of {\"q\", \"a\"}")
|
||||
f.IntVar(&epochs, "epochs", 6, "passes over the pairs")
|
||||
f.IntVar(&rank, "rank", 16, "the adapter's rank")
|
||||
f.Float64Var(&lr, "lr", 2e-4, "the learning rate")
|
||||
f.BoolVar(&noGGUF, "no-gguf", false, "skip the llama.cpp GGUF, even for a model llama.cpp serves")
|
||||
f.StringVar(&owner, "owner", "", "who the adapter belongs to — org:, team:, project: (path or ID), user: or key: — and only they see it "+
|
||||
"(default: the knowledge base's owner, when the pairs come from one)")
|
||||
f.BoolVar(&public, "public", false, "every caller of the model sees it; refused for pairs from a knowledge base")
|
||||
for _, req := range []string{"model", "base-repo", "node", "data"} {
|
||||
_ = start.MarkFlagRequired(req)
|
||||
}
|
||||
c.AddCommand(start)
|
||||
|
||||
c.AddCommand(&cobra.Command{
|
||||
Use: "list",
|
||||
Short: "Training jobs, newest first",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
var jobs []adminapi.TrainJobView
|
||||
if err := client().get(cmd.Context(), "/api/v1/train/jobs", &jobs); err != nil {
|
||||
return err
|
||||
}
|
||||
if *asJSON {
|
||||
return json.NewEncoder(cmd.OutOrStdout()).Encode(jobs)
|
||||
}
|
||||
if len(jobs) == 0 {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "No training jobs (llmgw ctl train start).")
|
||||
return nil
|
||||
}
|
||||
w := tab()
|
||||
fmt.Fprintln(w, "JOB\tADAPTER\tMODEL\tOWNER\tNODE\tSTATE\tPROGRESS\tLOSS\tPEAK\tCREATED")
|
||||
for _, j := range jobs {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", j.ID, j.Name, j.Model, or(j.Owner, "everyone"), j.Node, j.State,
|
||||
trainProgress(j), trainLoss(j), trainPeak(j), j.CreatedAt.Local().Format("01-02 15:04"))
|
||||
}
|
||||
return w.Flush()
|
||||
},
|
||||
})
|
||||
|
||||
var follow bool
|
||||
show := &cobra.Command{
|
||||
Use: "show JOB",
|
||||
Short: "How a training job goes, and what it made",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
out := cmd.OutOrStdout()
|
||||
last := ""
|
||||
for {
|
||||
var j adminapi.TrainJobView
|
||||
if err := client().get(cmd.Context(), "/api/v1/train/jobs/"+args[0], &j); err != nil {
|
||||
return err
|
||||
}
|
||||
if *asJSON {
|
||||
return json.NewEncoder(out).Encode(j)
|
||||
}
|
||||
line := fmt.Sprintf("%s %s %s loss %s peak %s %s", j.State, trainProgress(j), j.Node, trainLoss(j), trainPeak(j), j.Message)
|
||||
if !follow {
|
||||
fmt.Fprintf(out, "%s: adapter %s for %s, owned by %s, on %s (base %s), %d pairs\n", j.ID, j.Name, j.Model,
|
||||
or(j.Owner, "everyone"), j.Node, j.BaseRepo, j.Pairs)
|
||||
fmt.Fprintln(out, line)
|
||||
if len(j.Artifacts) > 0 {
|
||||
fmt.Fprintf(out, "made: %s (llmgw ctl train download %s --kind KIND)\n", strings.Join(j.Artifacts, ", "), j.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if line != last {
|
||||
fmt.Fprintf(out, "%s %s\n", time.Now().Format("15:04:05"), line)
|
||||
last = line
|
||||
}
|
||||
if store.TrainOver(j.State) {
|
||||
if j.State != store.TrainDone {
|
||||
return fmt.Errorf("%s %s", j.ID, j.State)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-cmd.Context().Done():
|
||||
return nil
|
||||
case <-time.After(3 * time.Second):
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
show.Flags().BoolVarP(&follow, "follow", "f", false, "follow it until it ends")
|
||||
c.AddCommand(show)
|
||||
|
||||
c.AddCommand(&cobra.Command{
|
||||
Use: "cancel JOB",
|
||||
Short: "Stop a training job",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := client().do(cmd.Context(), "POST", "/api/v1/train/jobs/"+args[0]+"/cancel", nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s cancelled\n", args[0])
|
||||
return nil
|
||||
},
|
||||
})
|
||||
c.AddCommand(&cobra.Command{
|
||||
Use: "rm JOB",
|
||||
Short: "Remove an ended training job and what it made",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := client().do(cmd.Context(), "DELETE", "/api/v1/train/jobs/"+args[0], nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s removed\n", args[0])
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
var restart bool
|
||||
publish := &cobra.Command{
|
||||
Use: "publish JOB",
|
||||
Short: "Make what a finished job made its model's adapter, kept on the mirror",
|
||||
Long: "The GGUF for a model llama.cpp serves, the PEFT files for one vLLM serves, are kept\n" +
|
||||
"on the mirror and added to the model's adapters; a request then asks for the\n" +
|
||||
"adapter by its name, as a model. llama.cpp loads adapters when a replica starts:\n" +
|
||||
"--restart replaces the model's replicas, one at a time, to load it.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var v adminapi.TrainPublishView
|
||||
if err := client().patient(15*time.Minute).do(cmd.Context(), "POST", "/api/v1/train/jobs/"+args[0]+"/publish", nil, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
out := cmd.OutOrStdout()
|
||||
fmt.Fprintf(out, "%s is %s's adapter, from %s\n", v.Adapter, v.Model, v.Source)
|
||||
if v.Owner != "" {
|
||||
fmt.Fprintf(out, "only %s sees it; to anyone else it is a model that does not exist\n", v.Owner)
|
||||
}
|
||||
if restart && strings.Contains(v.Note, "restart") {
|
||||
body, _ := json.Marshal(adminapi.RestartRequest{Models: []string{v.Model}})
|
||||
var res adminapi.RestartResult
|
||||
if err := client().post(cmd.Context(), "/api/v1/restart", body, &res); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(out, "%d replica(s) of %s restarting to load it; then ask for model %s\n", len(res.Queued), v.Model, v.Adapter)
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(out, v.Note)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
publish.Flags().BoolVar(&restart, "restart", false, "restart the model's replicas to load it (llama.cpp)")
|
||||
c.AddCommand(publish)
|
||||
|
||||
var kind, outFile string
|
||||
download := &cobra.Command{
|
||||
Use: "download JOB",
|
||||
Short: "Save what a training job made: the PEFT adapter (peft) or the GGUF (gguf)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if outFile == "" {
|
||||
outFile = args[0] + ".tar.gz"
|
||||
if kind == "gguf" {
|
||||
outFile = args[0] + ".gguf"
|
||||
}
|
||||
}
|
||||
f, err := os.Create(outFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
cw := &countWriter{w: f}
|
||||
if err := client().patient(10*time.Minute).do(cmd.Context(), "GET",
|
||||
"/api/v1/train/jobs/"+args[0]+"/artifacts/"+kind, nil, io.Writer(cw)); err != nil {
|
||||
_ = os.Remove(outFile)
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", outFile, humanBytes(cw.n))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
download.Flags().StringVar(&kind, "kind", "peft", "peft (a gzipped tar of the PEFT adapter) or gguf")
|
||||
download.Flags().StringVarP(&outFile, "out", "o", "", "the file to write")
|
||||
c.AddCommand(download)
|
||||
return c
|
||||
}
|
||||
|
||||
type countWriter struct {
|
||||
w io.Writer
|
||||
n int64
|
||||
}
|
||||
|
||||
func (c *countWriter) Write(p []byte) (int, error) {
|
||||
n, err := c.w.Write(p)
|
||||
c.n += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func trainProgress(j adminapi.TrainJobView) string {
|
||||
if j.Steps == 0 {
|
||||
return "-"
|
||||
}
|
||||
return fmt.Sprintf("%d/%d", j.Step, j.Steps)
|
||||
}
|
||||
|
||||
func trainLoss(j adminapi.TrainJobView) string {
|
||||
if j.Loss == 0 {
|
||||
return "-"
|
||||
}
|
||||
return fmt.Sprintf("%.3f", j.Loss)
|
||||
}
|
||||
|
||||
func trainPeak(j adminapi.TrainJobView) string {
|
||||
if j.PeakMiB == 0 {
|
||||
return "-"
|
||||
}
|
||||
return fmt.Sprintf("%d MiB", j.PeakMiB)
|
||||
}
|
||||
@@ -45,6 +45,7 @@ const (
|
||||
cUsage
|
||||
cQuotas
|
||||
cGuard
|
||||
cKB
|
||||
cFederation
|
||||
cMirror
|
||||
cJobs
|
||||
@@ -68,7 +69,7 @@ const (
|
||||
cCount
|
||||
)
|
||||
|
||||
var consoleLabels = [cCount]string{"Overview", "Monitor", "Nodes", "GPUs", "Models", "Replicas", "Usage", "Quotas", "Guardrails", "Federation", "Mirror", "Jobs", "Logs", "Accounts", "API keys", "Organizations", "Audit", "Manual",
|
||||
var consoleLabels = [cCount]string{"Overview", "Monitor", "Nodes", "GPUs", "Models", "Replicas", "Usage", "Quotas", "Guardrails", "Knowledge", "Federation", "Mirror", "Jobs", "Logs", "Accounts", "API keys", "Organizations", "Audit", "Manual",
|
||||
"server.yaml", "Add a node", "Add a control plane", "Replicate the store", "Serve a model", "Serve the guardrails' judge", "Upgrade the cluster",
|
||||
"Restart all replicas", "Back up now", "Wipe this machine", "Leave"}
|
||||
|
||||
@@ -177,6 +178,7 @@ type consoleModel struct {
|
||||
quotas []adminapi.QuotaView
|
||||
quotasErr error
|
||||
guard guardState
|
||||
kb kbState
|
||||
|
||||
st *storeHolder
|
||||
people peopleData
|
||||
@@ -323,6 +325,9 @@ func (m consoleModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.menu == cGuard {
|
||||
cmds = append(cmds, m.fetchGuard())
|
||||
}
|
||||
if m.menu == cKB {
|
||||
cmds = append(cmds, m.fetchKB())
|
||||
}
|
||||
if m.menu == cMirror {
|
||||
cmds = append(cmds, m.fetchMirror())
|
||||
}
|
||||
@@ -381,6 +386,8 @@ func (m consoleModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.people = peopleData(msg)
|
||||
case consoleQuotasMsg:
|
||||
m.quotas, m.quotasErr = msg.rows, msg.err
|
||||
case consoleKBMsg:
|
||||
m.kb = kbState{view: msg.view, err: msg.err, loaded: true}
|
||||
case consoleGuardMsg:
|
||||
window := m.guard.window
|
||||
m.guard = guardState{view: msg.view, events: msg.events, err: msg.err, window: window}
|
||||
@@ -539,6 +546,8 @@ func (m consoleModel) key(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.menuD = m.quotaMenu(m.quotas[m.row])
|
||||
case cGuard:
|
||||
m.menuD = m.guardAttachmentMenu(m.guard.view.Attachments[m.row])
|
||||
case cKB:
|
||||
m.menuD = m.kbMenu(m.kb.view.Collections[m.row])
|
||||
case cMirror:
|
||||
m.menuD = m.mirrorMenu(m.mirrorGroups()[m.row])
|
||||
case cJobs:
|
||||
@@ -596,6 +605,10 @@ func (m consoleModel) key(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.quotaForm(adminapi.QuotaView{})
|
||||
case cGuard:
|
||||
m.guardAttachForm()
|
||||
case cKB:
|
||||
if m.kb.view.Available {
|
||||
m.kbCreateForm()
|
||||
}
|
||||
case cModels:
|
||||
m.addModelForm()
|
||||
case cMirror:
|
||||
@@ -651,6 +664,8 @@ func (m consoleModel) onEnter() tea.Cmd {
|
||||
return m.fetchQuotas()
|
||||
case cGuard:
|
||||
return m.fetchGuard()
|
||||
case cKB:
|
||||
return m.fetchKB()
|
||||
case cMirror:
|
||||
return m.fetchMirror()
|
||||
case cMonitor:
|
||||
@@ -1027,6 +1042,8 @@ func (m consoleModel) rowCount() int {
|
||||
return len(m.quotas)
|
||||
case cGuard:
|
||||
return len(m.guard.view.Attachments)
|
||||
case cKB:
|
||||
return len(m.kb.view.Collections)
|
||||
case cMirror:
|
||||
return len(m.mirrorGroups())
|
||||
case cJobs:
|
||||
@@ -1260,6 +1277,8 @@ func (m consoleModel) panel() (title, body, keys string) {
|
||||
return m.quotasPanel(pw)
|
||||
case cGuard:
|
||||
return m.guardPanel(pw)
|
||||
case cKB:
|
||||
return m.kbPanel(pw)
|
||||
case cAccounts, cKeys, cOrgs:
|
||||
return m.peoplePanel(pw)
|
||||
case cManual:
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
)
|
||||
|
||||
// Knowledge bases, as the console shows them: each collection with its
|
||||
// owner, model, documents and grants; Enter for what can be done to one, A
|
||||
// to make one. People fill them from their own machines with llmgw-kb.
|
||||
|
||||
type kbState struct {
|
||||
view adminapi.KBView
|
||||
err error
|
||||
// loaded is set once an answer came, so "none yet" is not shown while
|
||||
// asking.
|
||||
loaded bool
|
||||
}
|
||||
|
||||
type consoleKBMsg struct {
|
||||
view adminapi.KBView
|
||||
err error
|
||||
}
|
||||
|
||||
func (m consoleModel) fetchKB() tea.Cmd {
|
||||
c := m.client
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var msg consoleKBMsg
|
||||
msg.err = c.get(ctx, "/api/v1/kb", &msg.view)
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
func (m consoleModel) kbPanel(pw int) (string, string, string) {
|
||||
keys := "→ or Enter open · ↑↓ choose · Q leave"
|
||||
if m.inPanel {
|
||||
keys = "↑↓ collection · Enter actions · A new collection · ← back · Q leave"
|
||||
}
|
||||
k := m.kb
|
||||
var b strings.Builder
|
||||
b.WriteString("\n " + sDim.Render("Documents requests are answered from; people add them with llmgw-kb.") + "\n\n")
|
||||
switch {
|
||||
case k.err != nil:
|
||||
b.WriteString(" " + sBad.Render(k.err.Error()))
|
||||
return "Knowledge", b.String(), keys
|
||||
case !k.loaded:
|
||||
b.WriteString(" " + sDim.Render("asking the control plane…"))
|
||||
return "Knowledge", b.String(), keys
|
||||
case !k.view.Available:
|
||||
b.WriteString(" " + sBad.Render("Off: ") + k.view.Reason)
|
||||
return "Knowledge", b.String(), keys
|
||||
case len(k.view.Collections) == 0:
|
||||
b.WriteString(" " + sDim.Render("No collections yet. A to make one."))
|
||||
return "Knowledge", b.String(), keys
|
||||
}
|
||||
var rows [][]string
|
||||
for _, c := range k.view.Collections {
|
||||
var grants []string
|
||||
for _, g := range c.Grants {
|
||||
grants = append(grants, or(g.Name, g.Target)+" "+g.Perm)
|
||||
}
|
||||
rows = append(rows, []string{or(c.OrgName, c.Org) + "/" + c.Name, or(c.OwnerName, c.Owner),
|
||||
fmt.Sprintf("%s (%d)", c.EmbedModel, c.Dims), fmt.Sprint(c.Documents), fmt.Sprint(c.Chunks), dash(strings.Join(grants, ", "))})
|
||||
}
|
||||
b.WriteString(table([]string{"COLLECTION", "OWNER", "MODEL", "DOCUMENTS", "CHUNKS", "GRANTED TO"}, rows, m.row, pw, m.inPanel))
|
||||
if len(k.view.Defaults) > 0 {
|
||||
b.WriteString("\n\n " + sTitle.Render("BY DEFAULT") + " " + sDim.Render("when a request names no collection") + "\n")
|
||||
var drows [][]string
|
||||
for _, d := range k.view.Defaults {
|
||||
drows = append(drows, []string{or(d.Name, d.Target), strings.Join(d.Collections, ", ")})
|
||||
}
|
||||
b.WriteString(table([]string{"FOR", "ANSWERED FROM"}, drows, -1, pw, false))
|
||||
}
|
||||
b.WriteString("\n\n " + sDim.Render("On a person's machine: llmgw-kb sync COLLECTION FOLDER"))
|
||||
return "Knowledge", b.String(), keys
|
||||
}
|
||||
|
||||
// kbRef is how the admin API is told which collection: by its ID.
|
||||
func kbRef(c adminapi.KBCollectionView) string { return url.PathEscape(c.ID) }
|
||||
|
||||
// kbCreateForm makes a collection.
|
||||
func (m *consoleModel) kbCreateForm() {
|
||||
model := ""
|
||||
for _, md := range m.data.models {
|
||||
n := strings.ToLower(md.Name)
|
||||
if strings.Contains(n, "embed") || strings.Contains(n, "bge") || strings.Contains(n, "e5") {
|
||||
model = md.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
m.openForm(&formDialog{title: "New knowledge-base collection",
|
||||
note: "Owned by part of the tree: it and everything below it may read and add to it.\n" +
|
||||
"The model must embed (capabilities.embeddings) and be able to serve now.",
|
||||
fields: []formField{
|
||||
choiceF("owner", []string{"project", "team", "org", "user"}, "team"),
|
||||
textF("which", "", "ORG/TEAM/PROJECT, ORG/TEAM, ORG, or the ID"),
|
||||
textF("org", "", "for a person's collection only"),
|
||||
textF("name", "", "how people name it to llmgw-kb"),
|
||||
textF("model", model, "the catalog model that embeds it"),
|
||||
},
|
||||
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
||||
for i, what := range map[int]string{1: "which " + v[0], 3: "a name", 4: "a model"} {
|
||||
if strings.TrimSpace(v[i]) == "" {
|
||||
return nil, fmt.Errorf("%s?", what)
|
||||
}
|
||||
}
|
||||
req := adminapi.KBCreateRequest{Owner: v[0] + ":" + v[1], Org: v[2], Name: v[3], Model: v[4]}
|
||||
return m.call("New collection "+v[3], http.MethodPost, "/api/v1/kb/collections", req, func(raw json.RawMessage) string {
|
||||
var c adminapi.KBCollectionView
|
||||
_ = json.Unmarshal(raw, &c)
|
||||
return fmt.Sprintf("Collection %s, embedded by %s in %d dimensions.\n\nPeople with access fill it with:\n llmgw-kb sync %s FOLDER",
|
||||
c.Name, c.EmbedModel, c.Dims, c.Name)
|
||||
}), nil
|
||||
}})
|
||||
}
|
||||
|
||||
// kbMenu is what can be done to one collection.
|
||||
func (m consoleModel) kbMenu(c adminapi.KBCollectionView) *menuDialog {
|
||||
name := or(c.OrgName, c.Org) + "/" + c.Name
|
||||
items := []menuItem{
|
||||
{"Documents", func(m *consoleModel) tea.Cmd { return m.kbDocuments(c) }},
|
||||
{"Grant…", func(m *consoleModel) tea.Cmd {
|
||||
m.openForm(&formDialog{title: "Grant " + name,
|
||||
note: "It and everything below it may then read, or with write add to, this collection.\nNever outside " + or(c.OrgName, c.Org) + ".",
|
||||
fields: []formField{
|
||||
choiceF("to", []string{"project", "team", "org", "user", "key"}, "project"),
|
||||
textF("which", "", "ORG/TEAM/PROJECT, ORG/TEAM, ORG, or the ID"),
|
||||
choiceF("may", []string{"read", "write"}, "read"),
|
||||
},
|
||||
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
||||
if strings.TrimSpace(v[1]) == "" {
|
||||
return nil, fmt.Errorf("which %s?", v[0])
|
||||
}
|
||||
req := adminapi.KBGrantRequest{Collection: c.ID, Target: v[0] + ":" + strings.TrimSpace(v[1]), Perm: v[2]}
|
||||
return m.call("Grant "+c.Name, http.MethodPost, "/api/v1/kb/grants", req, nil), nil
|
||||
}})
|
||||
return nil
|
||||
}},
|
||||
}
|
||||
for _, g := range c.Grants {
|
||||
items = append(items, menuItem{"Revoke " + or(g.Name, g.Target) + " (" + g.Perm + ")…", func(m *consoleModel) tea.Cmd {
|
||||
m.confirmD = &confirmDialog{title: "Revoke a grant on " + name,
|
||||
text: or(g.Name, g.Target) + " may then no longer " + g.Perm + " it, unless it owns it.",
|
||||
yes: func(m *consoleModel) tea.Cmd {
|
||||
return m.call("Revoke", http.MethodDelete, "/api/v1/kb/grants",
|
||||
adminapi.KBGrantRequest{Collection: c.ID, Target: g.Target}, nil)
|
||||
}}
|
||||
return nil
|
||||
}})
|
||||
}
|
||||
items = append(items,
|
||||
menuItem{"Answer from it by default…", func(m *consoleModel) tea.Cmd {
|
||||
m.openForm(&formDialog{title: "Answer from " + name + " by default",
|
||||
note: "Chat requests from there that name no collection are answered from this one.\n" +
|
||||
"Replaces that part's default; llmgw ctl kb default sets several.",
|
||||
fields: []formField{
|
||||
choiceF("for", []string{"project", "team", "org", "key"}, "team"),
|
||||
textF("which", "", "ORG/TEAM/PROJECT, ORG/TEAM, ORG, or the ID"),
|
||||
},
|
||||
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
||||
if strings.TrimSpace(v[1]) == "" {
|
||||
return nil, fmt.Errorf("which %s?", v[0])
|
||||
}
|
||||
req := adminapi.KBDefaultRequest{Target: v[0] + ":" + strings.TrimSpace(v[1]), Collections: []string{c.ID}}
|
||||
return m.call("Default "+c.Name, http.MethodPost, "/api/v1/kb/defaults", req, nil), nil
|
||||
}})
|
||||
return nil
|
||||
}},
|
||||
menuItem{"Try a search…", func(m *consoleModel) tea.Cmd {
|
||||
m.openForm(&formDialog{title: "Search " + name,
|
||||
note: "As an administrator: no tenancy applies. Shows what a question would find.",
|
||||
fields: []formField{textF("question", "", ""), textF("results", "5", "how many chunks")},
|
||||
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
||||
if v[0] == "" {
|
||||
return nil, fmt.Errorf("what question?")
|
||||
}
|
||||
k, _ := strconv.Atoi(v[1])
|
||||
req := adminapi.KBSearchRequest{Collection: c.ID, Query: v[0], K: k}
|
||||
return m.call("Search "+c.Name, http.MethodPost, "/api/v1/kb/search", req, func(raw json.RawMessage) string {
|
||||
var hits []adminapi.KBHitView
|
||||
_ = json.Unmarshal(raw, &hits)
|
||||
if len(hits) == 0 {
|
||||
return "Nothing found."
|
||||
}
|
||||
var b strings.Builder
|
||||
for i, h := range hits {
|
||||
text := h.Text
|
||||
if r := []rune(text); len(r) > 400 {
|
||||
text = string(r[:400]) + "…"
|
||||
}
|
||||
fmt.Fprintf(&b, "%d. %s #%d\n%s\n\n", i+1, h.Document, h.Ord, indent(strings.TrimSpace(text), " "))
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}), nil
|
||||
}})
|
||||
return nil
|
||||
}},
|
||||
menuItem{"Delete…", func(m *consoleModel) tea.Cmd {
|
||||
m.confirmD = &confirmDialog{title: "Delete " + name,
|
||||
text: fmt.Sprintf("Its %d documents and %d chunks, and every grant, go with it. People's own files are not touched.",
|
||||
c.Documents, c.Chunks),
|
||||
yes: func(m *consoleModel) tea.Cmd {
|
||||
return m.call("Delete "+c.Name, http.MethodDelete, "/api/v1/kb/collections/"+kbRef(c), nil, nil)
|
||||
}}
|
||||
return nil
|
||||
}},
|
||||
)
|
||||
return &menuDialog{title: name, items: items}
|
||||
}
|
||||
|
||||
// kbDocuments shows a collection's documents in a dialog.
|
||||
func (m *consoleModel) kbDocuments(c adminapi.KBCollectionView) tea.Cmd {
|
||||
return m.call("Documents of "+c.Name, http.MethodGet, "/api/v1/kb/collections/"+kbRef(c)+"/documents", nil,
|
||||
func(raw json.RawMessage) string {
|
||||
var docs []adminapi.KBDocumentView
|
||||
_ = json.Unmarshal(raw, &docs)
|
||||
if len(docs) == 0 {
|
||||
return "None yet. On a person's machine: llmgw-kb sync " + c.Name + " FOLDER"
|
||||
}
|
||||
var b strings.Builder
|
||||
for i, d := range docs {
|
||||
if i == 40 {
|
||||
fmt.Fprintf(&b, "… and %d more (llmgw ctl kb docs %s)", len(docs)-40, c.ID)
|
||||
break
|
||||
}
|
||||
fmt.Fprintf(&b, "%-48s %5d chunks %s %s\n", d.Name, d.Chunks, d.AddedAt.Local().Format("2006-01-02 15:04"), d.AddedBy)
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
)
|
||||
|
||||
func TestACollectionIsMadeAndGrantedFromTheConsole(t *testing.T) {
|
||||
m, s := consoleWith(t, cKB)
|
||||
m.data.models = append(m.data.models, adminapi.ModelView{Name: "bge-m3"})
|
||||
m.kb = kbState{loaded: true, view: adminapi.KBView{Available: true, Collections: []adminapi.KBCollectionView{{
|
||||
ID: "kb_aaaaaaaaaaaaaaaa", Name: "policies", Org: "o1", OrgName: "Acme", Owner: "team:t1", OwnerName: "Acme / hr",
|
||||
EmbedModel: "bge-m3", Dims: 1024, Documents: 3, Chunks: 40,
|
||||
Grants: []adminapi.KBGrantView{{Target: "project:p9", Name: "Acme/sales/crm", Perm: "read"}},
|
||||
}}}}
|
||||
var tm tea.Model = m
|
||||
view := tm.View()
|
||||
for _, want := range []string{"Knowledge", "Acme/policies", "Acme / hr", "bge-m3 (1024)", "Acme/s", "llmgw-kb sync COLLECTION FOLDER"} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Errorf("missing %q in:\n%s", want, view)
|
||||
}
|
||||
}
|
||||
|
||||
// A: the form, prefilled with the embedding model.
|
||||
tm = press(tm, "a")
|
||||
f := tm.(consoleModel).formD
|
||||
if f == nil || f.fields[4].value() != "bge-m3" {
|
||||
t.Fatalf("no form, or the model not offered: %+v", f)
|
||||
}
|
||||
f.fields[1].text.SetValue("Acme/hr")
|
||||
f.fields[3].text.SetValue("handbook")
|
||||
tm = pressRun(tm, "tab", "tab", "tab", "tab", "tab", "enter")
|
||||
var req adminapi.KBCreateRequest
|
||||
got := s.last()
|
||||
if !strings.HasPrefix(got, "POST /api/v1/kb/collections ") ||
|
||||
json.Unmarshal([]byte(strings.SplitN(got, " ", 3)[2]), &req) != nil ||
|
||||
req.Owner != "team:Acme/hr" || req.Name != "handbook" || req.Model != "bge-m3" {
|
||||
t.Fatalf("sent %q", got)
|
||||
}
|
||||
|
||||
// Enter on a row: grant to a project for writing.
|
||||
m.formD = nil
|
||||
tm = m
|
||||
tm = press(tm, "enter")
|
||||
if md := tm.(consoleModel).menuD; md == nil || !strings.Contains(md.items[2].label, "Revoke Acme/sales/crm") {
|
||||
t.Fatalf("menu %+v", md)
|
||||
}
|
||||
tm = press(tm, "down", "enter")
|
||||
f = tm.(consoleModel).formD
|
||||
f.fields[1].text.SetValue("Acme/sales/crm")
|
||||
f.fields[2].opt = 1 // write
|
||||
tm = pressRun(tm, "tab", "tab", "tab", "enter")
|
||||
if got := s.last(); got != `POST /api/v1/kb/grants {"collection":"kb_aaaaaaaaaaaaaaaa","target":"project:Acme/sales/crm","perm":"write"}` {
|
||||
t.Fatalf("sent %q", got)
|
||||
}
|
||||
_ = tm
|
||||
}
|
||||
@@ -18,7 +18,12 @@ import (
|
||||
//
|
||||
// An empty path loads the defaults, which with --server and --node-id on the
|
||||
// command line is enough for a smoke test on a node with no IB.
|
||||
func LoadAgent(path string) (agent.Config, error) {
|
||||
func LoadAgent(path string) (agent.Config, error) { return LoadAgentWith(path, nil) }
|
||||
|
||||
// LoadAgentWith is LoadAgent with the cluster's settings for this node
|
||||
// (agents.default and agents.nodes.<id>, as YAML) laid over the file: the
|
||||
// file is what the agent needs to start, the settings what it runs on.
|
||||
func LoadAgentWith(path string, settings []byte) (agent.Config, error) {
|
||||
cfg := agent.DefaultConfig()
|
||||
k := koanf.New(".")
|
||||
|
||||
@@ -30,6 +35,11 @@ func LoadAgent(path string) (agent.Config, error) {
|
||||
return cfg, fmt.Errorf("parsing %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
if len(settings) > 0 {
|
||||
if err := k.Load(bytesProvider(settings), yaml.Parser()); err != nil {
|
||||
return cfg, fmt.Errorf("parsing the cluster's settings for this agent: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
err := k.Load(env.Provider(".", env.Opt{
|
||||
Prefix: "LLMGW_",
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// AgentSettings are the nodes' agent settings, kept with the cluster's
|
||||
// (BACKLOG E3): everything agent.yaml can say except what an agent needs to
|
||||
// start — its id, the control planes to dial, its address and its
|
||||
// certificate. Default applies to every node, and Nodes, by node id, over
|
||||
// it; both over the node's own agent.yaml.
|
||||
type AgentSettings struct {
|
||||
// Default is agent.yaml's keys for every node.
|
||||
Default map[string]any `koanf:"default"`
|
||||
// Nodes are agent.yaml's keys for one node each, by node id, over
|
||||
// Default.
|
||||
Nodes map[string]map[string]any `koanf:"nodes"`
|
||||
}
|
||||
|
||||
// agentBootstrapKeys are what an agent needs before it can be told anything:
|
||||
// they stay in agent.yaml.
|
||||
var agentBootstrapKeys = []string{"node_id", "server", "servers", "hostname", "mgmt_addr", "tls"}
|
||||
|
||||
// For is the settings for one node: Default with the node's own over it.
|
||||
// Empty when there are none.
|
||||
func (a AgentSettings) For(node string) map[string]any {
|
||||
out := clone(stringKeys(a.Default))
|
||||
if n, ok := a.Nodes[node]; ok {
|
||||
merge(out, stringKeys(n))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Check refuses settings that set an agent's bootstrap keys, or that would
|
||||
// not load as an agent's configuration for some node.
|
||||
func (a AgentSettings) Check() error {
|
||||
check := func(where string, m map[string]any) error {
|
||||
for _, k := range agentBootstrapKeys {
|
||||
if _, ok := m[k]; ok {
|
||||
return fmt.Errorf("%s.%s stays in each node's agent.yaml: an agent needs it before it can be told anything", where, k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := check("agents.default", stringKeys(a.Default)); err != nil {
|
||||
return err
|
||||
}
|
||||
nodes := make([]string, 0, len(a.Nodes))
|
||||
for n := range a.Nodes {
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
sort.Strings(nodes)
|
||||
for _, n := range nodes {
|
||||
if err := check("agents.nodes."+n, stringKeys(a.Nodes[n])); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Each node's settings as its agent would load them, over nothing: what
|
||||
// its own file adds cannot be known here, and what is wrong in the
|
||||
// settings alone is wrong on every node.
|
||||
for _, n := range append([]string{""}, nodes...) {
|
||||
b, err := Marshal(a.For(n))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := LoadAgentWith("", b); err != nil {
|
||||
where := "agents.default"
|
||||
if n != "" {
|
||||
where = "agents.nodes." + n
|
||||
}
|
||||
return fmt.Errorf("%s: %w", where, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AgentBootstrapKeys are the keys agent.yaml keeps, for saying so.
|
||||
func AgentBootstrapKeys() []string { return slices.Clone(agentBootstrapKeys) }
|
||||
|
||||
// stringKeys turns YAML's map[any]any, wherever it hides, into the
|
||||
// map[string]any the rest of this package merges.
|
||||
func stringKeys(m map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(m))
|
||||
for k, v := range m {
|
||||
out[k] = normalize(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalize(v any) any {
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
return stringKeys(t)
|
||||
case map[any]any:
|
||||
m := make(map[string]any, len(t))
|
||||
for k, vv := range t {
|
||||
m[fmt.Sprint(k)] = normalize(vv)
|
||||
}
|
||||
return m
|
||||
case []any:
|
||||
out := make([]any, len(t))
|
||||
for i, vv := range t {
|
||||
out[i] = normalize(vv)
|
||||
}
|
||||
return out
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestANodesAgentSettingsAreTheDefaultWithItsOwnOver(t *testing.T) {
|
||||
a := AgentSettings{
|
||||
Default: map[string]any{"heartbeat_interval": "5s", "supervisor": map[string]any{"work_dir": "/srv/w", "x": 1}},
|
||||
Nodes: map[string]map[string]any{
|
||||
"cuda01": {"supervisor": map[string]any{"work_dir": "/fast/w"}, "labels": map[string]any{"gpu": "rtx6000"}},
|
||||
},
|
||||
}
|
||||
got := a.For("cuda01")
|
||||
sup := got["supervisor"].(map[string]any)
|
||||
if sup["work_dir"] != "/fast/w" || sup["x"] != 1 || got["heartbeat_interval"] != "5s" || got["labels"] == nil {
|
||||
t.Errorf("cuda01: %v", got)
|
||||
}
|
||||
if got := a.For("node01"); got["labels"] != nil || got["supervisor"].(map[string]any)["work_dir"] != "/srv/w" {
|
||||
t.Errorf("node01: %v", got)
|
||||
}
|
||||
if got := (AgentSettings{}).For("x"); len(got) != 0 {
|
||||
t.Errorf("none: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentSettingsKeepBootstrapKeysInTheFileAndMustLoad(t *testing.T) {
|
||||
for _, a := range []AgentSettings{
|
||||
{Default: map[string]any{"servers": []any{"x:7443"}}},
|
||||
{Nodes: map[string]map[string]any{"n1": {"node_id": "other"}}},
|
||||
{Nodes: map[string]map[string]any{"n1": {"tls": map[string]any{"ca": "/x"}}}},
|
||||
} {
|
||||
if err := a.Check(); err == nil || !strings.Contains(err.Error(), "stays in each node's agent.yaml") {
|
||||
t.Errorf("%+v: %v", a, err)
|
||||
}
|
||||
}
|
||||
// A fabric with no adapters would not load on the node it names.
|
||||
bad := AgentSettings{Nodes: map[string]map[string]any{"n1": {"fabrics": []any{map[string]any{"name": "ib-a"}}}}}
|
||||
if err := bad.Check(); err == nil || !strings.Contains(err.Error(), "agents.nodes.n1") || !strings.Contains(err.Error(), "no HCAs") {
|
||||
t.Errorf("a fabric without HCAs: %v", err)
|
||||
}
|
||||
if err := (AgentSettings{Default: map[string]any{"heartbeat_interval": "3s"}}).Check(); err != nil {
|
||||
t.Errorf("a plain setting: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheClusterSettingsCarryAgentsAndRefuseBadOnes(t *testing.T) {
|
||||
good, err := Parse([]byte("agents:\n default:\n heartbeat_interval: 3s\n nodes:\n cuda01:\n labels: {gpu: rtx6000}\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := FromMap(good)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Agents.For("cuda01")["labels"] == nil {
|
||||
t.Errorf("agents not read: %+v", cfg.Agents)
|
||||
}
|
||||
bad, _ := Parse([]byte("agents:\n default:\n mgmt_addr: 10.0.0.1\n"))
|
||||
if _, err := FromMap(bad); err == nil {
|
||||
t.Error("a bootstrap key in the cluster's settings was taken")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSettingsAreLaidOverTheAgentsFile(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "agent.yaml")
|
||||
_ = os.WriteFile(p, []byte("node_id: n1\nserver: cp:7443\nheartbeat_interval: 9s\nlabels: {rack: r1}\n"), 0o600)
|
||||
cfg, err := LoadAgentWith(p, []byte("heartbeat_interval: 2s\nlabels: {gpu: a100}\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.NodeID != "n1" || cfg.HeartbeatInterval != 2*time.Second || cfg.Labels["rack"] != "r1" || cfg.Labels["gpu"] != "a100" {
|
||||
t.Errorf("loaded %+v", cfg)
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,13 @@ type Server struct {
|
||||
// Firewall turns llmgw's firewall on every machine on or off.
|
||||
Firewall FirewallSettings `koanf:"firewall"`
|
||||
|
||||
// Agents are the nodes' agent settings: agent.yaml's keys but those an
|
||||
// agent needs to start, for every node (default) and for one
|
||||
// (nodes.<id>). Sent to the agents as they change; an agent whose
|
||||
// settings changed starts again on them, its engines serving
|
||||
// throughout.
|
||||
Agents AgentSettings `koanf:"agents"`
|
||||
|
||||
// FirewallNetworks are networks, besides those each machine reaches the
|
||||
// cluster on, that llmgw's internal ports answer: a monitoring host on
|
||||
// another subnet, nodes behind a router. Written into every machine's
|
||||
@@ -887,6 +894,9 @@ func load(b []byte) (Server, error) {
|
||||
// Validate rejects configurations that would otherwise fail confusingly at
|
||||
// request time.
|
||||
func (s Server) Validate() error {
|
||||
if err := s.Agents.Check(); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, o := range s.EngineOverrides {
|
||||
if o.GPUClass == "" && len(o.Nodes) == 0 && len(o.Labels) == 0 {
|
||||
return fmt.Errorf("engine_overrides[%d] picks no nodes: give it a gpu_class, nodes or labels", i)
|
||||
|
||||
@@ -68,7 +68,13 @@ type API struct {
|
||||
// nil without a store.
|
||||
federation *federation.Service
|
||||
// guard is the guardrail policies' library and attachments.
|
||||
guard *GuardAdmin
|
||||
guard *GuardAdmin
|
||||
// kb is knowledge bases; nil without a store.
|
||||
kb *KBAdmin
|
||||
// tenancy is organizations, keys and people; nil without a store.
|
||||
tenancy TenancyStore
|
||||
// train is adapter training; nil without a store and agents.
|
||||
train *TrainAdmin
|
||||
settings *Settings
|
||||
// restartAgents tells every connected agent to start again.
|
||||
restartAgents func(reason string) []string
|
||||
@@ -114,6 +120,9 @@ func (a *API) Register(mux Mux) {
|
||||
mux.HandleFunc("POST /api/v1/models/{name}/scale", a.scaleModel)
|
||||
mux.HandleFunc("POST /api/v1/models/{name}/resources", a.setResources)
|
||||
a.registerQuotas(mux)
|
||||
a.registerKB(mux)
|
||||
a.registerTenancy(mux)
|
||||
a.registerTrain(mux)
|
||||
a.registerGuard(mux)
|
||||
a.registerSettings(mux)
|
||||
mux.HandleFunc("POST /api/v1/agents/restart", a.restartAgentsHandler)
|
||||
@@ -1960,32 +1969,9 @@ func (a *API) setAdapters(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "no model "+name)
|
||||
return
|
||||
}
|
||||
base, baseErr := gguf.KindFromURL(r.Context(), safedial.Client(safedial.ControlPlane, 30*time.Second), m.Source)
|
||||
have := map[string]bool{}
|
||||
for _, ad := range m.Adapters {
|
||||
have[ad.Source] = true
|
||||
}
|
||||
// The file is read only where llama.cpp will load it: a vLLM adapter is
|
||||
// a PEFT directory, which has no header to read, and vLLM itself refuses
|
||||
// one that does not fit the model when it is loaded.
|
||||
ggufs := slices.ContainsFunc(m.Profiles, func(p catalog.Profile) bool { return p.Engine == "llamacpp" })
|
||||
for _, ad := range req.Adapters {
|
||||
if have[ad.Source] || !ggufs {
|
||||
continue // already checked when it was attached
|
||||
}
|
||||
h, err := gguf.KindFromURL(r.Context(), safedial.Client(safedial.ControlPlane, 30*time.Second), ad.Source)
|
||||
switch {
|
||||
case err != nil:
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("adapter %s: %v", ad.Name, err))
|
||||
return
|
||||
case !h.IsAdapter():
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("adapter %s: %s is a model, not a LoRA adapter", ad.Name, ad.Source))
|
||||
return
|
||||
case baseErr == nil && h.Architecture != base.Architecture:
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("adapter %s is for %s models and %s is %s",
|
||||
ad.Name, h.Architecture, m.Name, base.Architecture))
|
||||
return
|
||||
}
|
||||
if err := checkAdapters(r.Context(), m, req.Adapters); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
out, err := a.models.SetAdapters(r.Context(), m.Name, req.Adapters, actorOf(r))
|
||||
if err != nil {
|
||||
@@ -2067,3 +2053,36 @@ func multiNodeReadiness(n inventory.Node) string {
|
||||
}
|
||||
return "ready"
|
||||
}
|
||||
|
||||
// checkAdapters reads each adapter a model does not have yet, where
|
||||
// llama.cpp will load it, to refuse one that is not a LoRA adapter for the
|
||||
// model's architecture: such an adapter is a replica that fails to start,
|
||||
// found minutes later on a node.
|
||||
func checkAdapters(ctx context.Context, m catalog.Model, adapters []catalog.Adapter) error {
|
||||
// A vLLM adapter is a PEFT directory, which has no header to read, and
|
||||
// vLLM itself refuses one that does not fit the model when it is loaded.
|
||||
if !slices.ContainsFunc(m.Profiles, func(p catalog.Profile) bool { return p.Engine == "llamacpp" }) {
|
||||
return nil
|
||||
}
|
||||
client := safedial.Client(safedial.ControlPlane, 30*time.Second)
|
||||
base, baseErr := gguf.KindFromURL(ctx, client, m.Source)
|
||||
have := map[string]bool{}
|
||||
for _, ad := range m.Adapters {
|
||||
have[ad.Source] = true
|
||||
}
|
||||
for _, ad := range adapters {
|
||||
if have[ad.Source] {
|
||||
continue // checked when it was attached
|
||||
}
|
||||
h, err := gguf.KindFromURL(ctx, client, ad.Source)
|
||||
switch {
|
||||
case err != nil:
|
||||
return fmt.Errorf("adapter %s: %v", ad.Name, err)
|
||||
case !h.IsAdapter():
|
||||
return fmt.Errorf("adapter %s: %s is a model, not a LoRA adapter", ad.Name, ad.Source)
|
||||
case baseErr == nil && h.Architecture != base.Architecture:
|
||||
return fmt.Errorf("adapter %s is for %s models and %s is %s", ad.Name, h.Architecture, m.Name, base.Architecture)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
// Knowledge bases, administered (BACKLOG F): collections made, granted,
|
||||
// emptied and removed here; documents put into them by people with
|
||||
// llmgw-kb, through the gateway.
|
||||
|
||||
// KBStore is the part of the store knowledge bases need.
|
||||
type KBStore interface {
|
||||
KnowledgeAvailable() error
|
||||
Collections(ctx context.Context) ([]store.KBCollection, error)
|
||||
Collection(ctx context.Context, id string) (store.KBCollection, error)
|
||||
CreateCollection(ctx context.Context, c store.KBCollection) (store.KBCollection, error)
|
||||
DeleteCollection(ctx context.Context, id string) error
|
||||
Grant(ctx context.Context, g store.KBGrant) error
|
||||
Revoke(ctx context.Context, scope store.Scope, scopeID, collection string) (bool, error)
|
||||
Grants(ctx context.Context, collection string) ([]store.KBGrant, error)
|
||||
Documents(ctx context.Context, collection string) ([]store.KBDocument, error)
|
||||
DeleteDocument(ctx context.Context, collection, name string) (bool, error)
|
||||
SearchChunks(ctx context.Context, q store.KBSearch) ([]store.KBHit, error)
|
||||
SetKBDefault(ctx context.Context, d store.KBDefault) error
|
||||
CollectionChunks(ctx context.Context, c store.KBCollection, limit int) ([]store.KBHit, error)
|
||||
CollectionsFor(ctx context.Context, e store.Effective, write bool) ([]store.KBCollection, error)
|
||||
EffectiveFor(ctx context.Context, scope store.Scope, id, org string) (store.Effective, error)
|
||||
KBDefaults(ctx context.Context) ([]store.KBDefault, error)
|
||||
}
|
||||
|
||||
// KBAdmin is what the knowledge-base endpoints need: the store, and a way to
|
||||
// have a model embed a text (to learn its dimension, and to try a search).
|
||||
type KBAdmin struct {
|
||||
Store KBStore
|
||||
Embed func(ctx context.Context, model, text string) ([]float32, error)
|
||||
}
|
||||
|
||||
// SetKB enables the knowledge-base endpoints.
|
||||
func (a *API) SetKB(k *KBAdmin) { a.kb = k }
|
||||
|
||||
// KBView is every collection, or why there can be none.
|
||||
type KBView struct {
|
||||
Available bool `json:"available"`
|
||||
// Reason is why not, when not.
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Collections []KBCollectionView `json:"collections"`
|
||||
// Defaults are the collections parts of the tree answer from when a
|
||||
// request names none.
|
||||
Defaults []KBDefaultView `json:"defaults"`
|
||||
}
|
||||
|
||||
// KBDefaultView is one part of the tree's default collections.
|
||||
type KBDefaultView struct {
|
||||
Target string `json:"target"`
|
||||
Name string `json:"name,omitempty"`
|
||||
// Collections are ORG/NAME, in order.
|
||||
Collections []string `json:"collections"`
|
||||
At time.Time `json:"at"`
|
||||
By string `json:"by,omitempty"`
|
||||
}
|
||||
|
||||
// KBDefaultRequest sets the collections a part of the tree answers from
|
||||
// when a request names none; none clears it.
|
||||
type KBDefaultRequest struct {
|
||||
// Target is org:, team:, project: (by path or ID) or key:KEY-ID.
|
||||
Target string `json:"target"`
|
||||
// Collections are IDs, names when unique, or ORG/NAME.
|
||||
Collections []string `json:"collections"`
|
||||
}
|
||||
|
||||
// KBCollectionView is a collection as an administrator sees it.
|
||||
type KBCollectionView struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Org string `json:"org"`
|
||||
OrgName string `json:"org_name,omitempty"`
|
||||
Owner string `json:"owner"`
|
||||
OwnerName string `json:"owner_name,omitempty"`
|
||||
EmbedModel string `json:"embed_model"`
|
||||
Dims int `json:"dims"`
|
||||
Documents int `json:"documents"`
|
||||
Chunks int `json:"chunks"`
|
||||
// Grants are who else may read or write it.
|
||||
Grants []KBGrantView `json:"grants"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CreatedBy string `json:"created_by,omitempty"`
|
||||
}
|
||||
|
||||
// KBGrantView is one grant.
|
||||
type KBGrantView struct {
|
||||
Target string `json:"target"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Perm string `json:"perm"`
|
||||
At time.Time `json:"at"`
|
||||
By string `json:"by,omitempty"`
|
||||
}
|
||||
|
||||
// KBCreateRequest makes a collection.
|
||||
type KBCreateRequest struct {
|
||||
// Owner is org:NAME, team:ORG/TEAM, project:ORG/TEAM/PROJECT (or the ID
|
||||
// after the colon), or user:USER-ID.
|
||||
Owner string `json:"owner"`
|
||||
// Org is the organization a person's collection belongs to; only for
|
||||
// user: owners.
|
||||
Org string `json:"org,omitempty"`
|
||||
Name string `json:"name"`
|
||||
// Model is the catalog model that embeds its documents.
|
||||
Model string `json:"model"`
|
||||
// Dims is the model's embedding dimension; asked of the model when 0.
|
||||
Dims int `json:"dims,omitempty"`
|
||||
}
|
||||
|
||||
// KBGrantRequest lets a scope read or write a collection, or with DELETE
|
||||
// takes that away.
|
||||
type KBGrantRequest struct {
|
||||
// Collection is its ID, its name when that is unique, or ORG/NAME.
|
||||
Collection string `json:"collection"`
|
||||
// Target is org:, team:, project:, key: or user: as for guard policies.
|
||||
Target string `json:"target"`
|
||||
// Perm is read or write; ignored by DELETE.
|
||||
Perm string `json:"perm,omitempty"`
|
||||
}
|
||||
|
||||
// KBDocumentView is one document in a collection.
|
||||
type KBDocumentView struct {
|
||||
Name string `json:"name"`
|
||||
Digest string `json:"digest"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
Chunks int `json:"chunks"`
|
||||
AddedAt time.Time `json:"added_at"`
|
||||
AddedBy string `json:"added_by,omitempty"`
|
||||
}
|
||||
|
||||
// KBSearchRequest tries a search, as an administrator: no tenancy applies.
|
||||
type KBSearchRequest struct {
|
||||
Collection string `json:"collection"`
|
||||
Query string `json:"query"`
|
||||
K int `json:"k,omitempty"`
|
||||
}
|
||||
|
||||
// KBHitView is one chunk a search found.
|
||||
type KBHitView struct {
|
||||
Document string `json:"document"`
|
||||
Ord int `json:"ord"`
|
||||
Score float64 `json:"score"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
func (a *API) registerKB(mux Mux) {
|
||||
mux.HandleFunc("GET /api/v1/kb", a.kbList)
|
||||
mux.HandleFunc("POST /api/v1/kb/collections", a.kbCreate)
|
||||
mux.HandleFunc("DELETE /api/v1/kb/collections/{id}", a.kbDelete)
|
||||
mux.HandleFunc("GET /api/v1/kb/collections/{id}/documents", a.kbDocuments)
|
||||
mux.HandleFunc("DELETE /api/v1/kb/collections/{id}/documents", a.kbDeleteDocument)
|
||||
mux.HandleFunc("POST /api/v1/kb/grants", a.kbGrant(true))
|
||||
mux.HandleFunc("DELETE /api/v1/kb/grants", a.kbGrant(false))
|
||||
mux.HandleFunc("POST /api/v1/kb/search", a.kbSearch)
|
||||
mux.HandleFunc("POST /api/v1/kb/defaults", a.kbSetDefault)
|
||||
mux.HandleFunc("POST /api/v1/kb/collections/{id}/dataset", a.kbDataset)
|
||||
}
|
||||
|
||||
// kbReady answers for a gateway without knowledge bases, and says whether
|
||||
// it did.
|
||||
func (a *API) kbReady(w http.ResponseWriter) bool {
|
||||
if a.kb == nil {
|
||||
writeError(w, http.StatusNotImplemented, "knowledge bases need the store, which this control plane runs without")
|
||||
return false
|
||||
}
|
||||
if err := a.kb.Store.KnowledgeAvailable(); err != nil {
|
||||
writeError(w, http.StatusNotImplemented, err.Error())
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *API) names(ctx context.Context) map[string]string {
|
||||
if a.quotas == nil {
|
||||
return map[string]string{}
|
||||
}
|
||||
n, err := a.scopeNames(ctx)
|
||||
if err != nil {
|
||||
return map[string]string{}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (a *API) kbList(w http.ResponseWriter, r *http.Request) {
|
||||
if a.kb == nil {
|
||||
writeJSON(w, http.StatusOK, KBView{Reason: "knowledge bases need the store", Collections: []KBCollectionView{}, Defaults: []KBDefaultView{}})
|
||||
return
|
||||
}
|
||||
if err := a.kb.Store.KnowledgeAvailable(); err != nil {
|
||||
writeJSON(w, http.StatusOK, KBView{Reason: err.Error(), Collections: []KBCollectionView{}, Defaults: []KBDefaultView{}})
|
||||
return
|
||||
}
|
||||
cs, err := a.kb.Store.Collections(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
grants, err := a.kb.Store.Grants(r.Context(), "")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
names := a.names(r.Context())
|
||||
v := KBView{Available: true, Collections: []KBCollectionView{}, Defaults: []KBDefaultView{}}
|
||||
for _, c := range cs {
|
||||
cv := KBCollectionView{ID: c.ID, Name: c.Name, Org: c.OrgID, OrgName: names["org:"+c.OrgID],
|
||||
Owner: string(c.OwnerScope) + ":" + c.OwnerID, OwnerName: names[string(c.OwnerScope)+":"+c.OwnerID],
|
||||
EmbedModel: c.EmbedModel, Dims: c.Dims, Grants: []KBGrantView{}, CreatedAt: c.CreatedAt, CreatedBy: c.CreatedBy}
|
||||
if docs, err := a.kb.Store.Documents(r.Context(), c.ID); err == nil {
|
||||
cv.Documents = len(docs)
|
||||
for _, d := range docs {
|
||||
cv.Chunks += d.Chunks
|
||||
}
|
||||
}
|
||||
for _, g := range grants {
|
||||
if g.Collection == c.ID {
|
||||
key := string(g.Scope) + ":" + g.ScopeID
|
||||
cv.Grants = append(cv.Grants, KBGrantView{Target: key, Name: names[key], Perm: string(g.Perm), At: g.AddedAt, By: g.AddedBy})
|
||||
}
|
||||
}
|
||||
v.Collections = append(v.Collections, cv)
|
||||
}
|
||||
label := map[string]string{}
|
||||
for _, c := range cs {
|
||||
label[c.ID] = or(names["org:"+c.OrgID], c.OrgID) + "/" + c.Name
|
||||
}
|
||||
defaults, err := a.kb.Store.KBDefaults(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
for _, d := range defaults {
|
||||
key := string(d.Scope) + ":" + d.ScopeID
|
||||
dv := KBDefaultView{Target: key, Name: names[key], Collections: []string{}, At: d.AddedAt, By: d.AddedBy}
|
||||
for _, id := range d.Collections {
|
||||
dv.Collections = append(dv.Collections, or(label[id], id))
|
||||
}
|
||||
v.Defaults = append(v.Defaults, dv)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, v)
|
||||
}
|
||||
|
||||
// kbTarget reads a grant's or an owner's target: org:, team: and project:
|
||||
// by path or ID, key: and user: by ID.
|
||||
func (a *API) kbTarget(ctx context.Context, target string) (store.Scope, string, error) {
|
||||
kind, what, ok := strings.Cut(strings.TrimSpace(target), ":")
|
||||
what = strings.TrimSpace(what)
|
||||
if !ok || what == "" {
|
||||
return "", "", fmt.Errorf("%q is not org:, team:, project:, key: or user: and what", target)
|
||||
}
|
||||
switch store.Scope(kind) {
|
||||
case store.ScopeKey, store.ScopeUser:
|
||||
return store.Scope(kind), what, nil
|
||||
case store.ScopeOrg, store.ScopeTeam, store.ScopeProject:
|
||||
if a.quotas == nil {
|
||||
return store.Scope(kind), what, nil
|
||||
}
|
||||
scope, id, _, err := a.resolveScope(ctx, target)
|
||||
return scope, id, err
|
||||
}
|
||||
return "", "", fmt.Errorf("%q: org, team, project, key or user", kind)
|
||||
}
|
||||
|
||||
// kbFind reads a collection by ID, by its name when that is unique, or by
|
||||
// ORG/NAME.
|
||||
func (a *API) kbFind(ctx context.Context, ref string) (store.KBCollection, error) {
|
||||
ref = strings.TrimSpace(ref)
|
||||
if c, err := a.kb.Store.Collection(ctx, ref); err == nil {
|
||||
return c, nil
|
||||
}
|
||||
cs, err := a.kb.Store.Collections(ctx)
|
||||
if err != nil {
|
||||
return store.KBCollection{}, err
|
||||
}
|
||||
names := a.names(ctx)
|
||||
org, name, scoped := strings.Cut(ref, "/")
|
||||
var found []store.KBCollection
|
||||
for _, c := range cs {
|
||||
switch {
|
||||
case scoped && strings.EqualFold(c.Name, name) &&
|
||||
(c.OrgID == org || strings.EqualFold(names["org:"+c.OrgID], org)):
|
||||
found = append(found, c)
|
||||
case !scoped && strings.EqualFold(c.Name, ref):
|
||||
found = append(found, c)
|
||||
}
|
||||
}
|
||||
switch len(found) {
|
||||
case 0:
|
||||
return store.KBCollection{}, fmt.Errorf("no collection %q", ref)
|
||||
case 1:
|
||||
return found[0], nil
|
||||
}
|
||||
return store.KBCollection{}, fmt.Errorf("%q names a collection in more than one organization; write ORG/%s", ref, ref)
|
||||
}
|
||||
|
||||
func (a *API) kbCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.kbReady(w) {
|
||||
return
|
||||
}
|
||||
var in KBCreateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
scope, id, err := a.kbTarget(r.Context(), in.Owner)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
org := ""
|
||||
if scope == store.ScopeUser {
|
||||
if in.Org == "" {
|
||||
writeError(w, http.StatusBadRequest, "a person's collection names its organization (org)")
|
||||
return
|
||||
}
|
||||
if _, org, err = a.kbTarget(r.Context(), "org:"+in.Org); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
m, ok := a.ctl.Catalog().Resolve(in.Model)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "no model "+in.Model+" in the catalog")
|
||||
return
|
||||
}
|
||||
if !m.Capabilities.Embeddings {
|
||||
writeError(w, http.StatusBadRequest, m.Name+" does not embed (capabilities.embeddings); a collection needs an embedding model")
|
||||
return
|
||||
}
|
||||
dims := in.Dims
|
||||
if dims == 0 {
|
||||
if a.kb.Embed == nil {
|
||||
writeError(w, http.StatusBadRequest, "give the model's dimension (dims): nothing here can ask it")
|
||||
return
|
||||
}
|
||||
// Asked of the model: a dimension typed by hand and wrong would be
|
||||
// found only when the first document is refused.
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
|
||||
defer cancel()
|
||||
v, err := a.kb.Embed(ctx, m.Name, "llmgw")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "asking "+m.Name+" for its dimension: "+err.Error()+
|
||||
" (is it running? or give dims)")
|
||||
return
|
||||
}
|
||||
dims = len(v)
|
||||
}
|
||||
c, err := a.kb.Store.CreateCollection(r.Context(), store.KBCollection{OwnerScope: scope, OwnerID: id, OrgID: org,
|
||||
Name: in.Name, EmbedModel: m.Name, Dims: dims, CreatedBy: actorOf(r)})
|
||||
if err != nil {
|
||||
code := http.StatusBadRequest
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
code = http.StatusNotFound
|
||||
}
|
||||
writeError(w, code, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, KBCollectionView{ID: c.ID, Name: c.Name, Org: c.OrgID,
|
||||
Owner: string(c.OwnerScope) + ":" + c.OwnerID, EmbedModel: c.EmbedModel, Dims: c.Dims,
|
||||
Grants: []KBGrantView{}, CreatedAt: c.CreatedAt, CreatedBy: c.CreatedBy})
|
||||
}
|
||||
|
||||
func (a *API) kbCollection(w http.ResponseWriter, r *http.Request) (store.KBCollection, bool) {
|
||||
if !a.kbReady(w) {
|
||||
return store.KBCollection{}, false
|
||||
}
|
||||
c, err := a.kbFind(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return store.KBCollection{}, false
|
||||
}
|
||||
return c, true
|
||||
}
|
||||
|
||||
func (a *API) kbDelete(w http.ResponseWriter, r *http.Request) {
|
||||
c, ok := a.kbCollection(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := a.kb.Store.DeleteCollection(r.Context(), c.ID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"id": c.ID, "name": c.Name, "deleted": true})
|
||||
}
|
||||
|
||||
func (a *API) kbDocuments(w http.ResponseWriter, r *http.Request) {
|
||||
c, ok := a.kbCollection(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
docs, err := a.kb.Store.Documents(r.Context(), c.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
out := []KBDocumentView{}
|
||||
for _, d := range docs {
|
||||
out = append(out, KBDocumentView{Name: d.Name, Digest: d.Digest, Bytes: d.Bytes, Chunks: d.Chunks,
|
||||
AddedAt: d.AddedAt, AddedBy: d.AddedBy})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (a *API) kbDeleteDocument(w http.ResponseWriter, r *http.Request) {
|
||||
c, ok := a.kbCollection(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
name := r.URL.Query().Get("name")
|
||||
gone, err := a.kb.Store.DeleteDocument(r.Context(), c.ID, name)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !gone {
|
||||
writeError(w, http.StatusNotFound, "no document "+name+" in "+c.Name)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"name": name, "deleted": true})
|
||||
}
|
||||
|
||||
func (a *API) kbGrant(grant bool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.kbReady(w) {
|
||||
return
|
||||
}
|
||||
var in KBGrantRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
c, err := a.kbFind(r.Context(), in.Collection)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
scope, id, err := a.kbTarget(r.Context(), in.Target)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if !grant {
|
||||
gone, err := a.kb.Store.Revoke(r.Context(), scope, id, c.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !gone {
|
||||
writeError(w, http.StatusNotFound, "no such grant")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, KBGrantView{Target: string(scope) + ":" + id})
|
||||
return
|
||||
}
|
||||
perm := store.KBPerm(strings.ToLower(strings.TrimSpace(in.Perm)))
|
||||
if perm == "" {
|
||||
perm = store.KBRead
|
||||
}
|
||||
if err := a.kb.Store.Grant(r.Context(), store.KBGrant{Scope: scope, ScopeID: id, Collection: c.ID,
|
||||
Perm: perm, AddedBy: actorOf(r)}); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, KBGrantView{Target: string(scope) + ":" + id, Perm: string(perm), At: time.Now(), By: actorOf(r)})
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) kbSearch(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.kbReady(w) {
|
||||
return
|
||||
}
|
||||
var in KBSearchRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
c, err := a.kbFind(r.Context(), in.Collection)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
k := in.K
|
||||
if k <= 0 || k > 50 {
|
||||
k = 5
|
||||
}
|
||||
q := store.KBSearch{OrgID: c.OrgID, Collections: []store.KBCollection{c}, Text: in.Query, K: k}
|
||||
// Said, not swallowed: a search that quietly lost its meaning half
|
||||
// looks like a collection that does not know the answer.
|
||||
if a.kb.Embed != nil {
|
||||
v, err := a.kb.Embed(r.Context(), c.EmbedModel, in.Query)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, c.EmbedModel+" did not embed the question: "+err.Error())
|
||||
return
|
||||
}
|
||||
q.Embeddings = map[string][]float32{c.EmbedModel: v}
|
||||
}
|
||||
hits, err := a.kb.Store.SearchChunks(r.Context(), q)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
out := []KBHitView{}
|
||||
for _, h := range hits {
|
||||
out = append(out, KBHitView{Document: h.Document, Ord: h.Ord, Score: h.Score, Text: h.Body})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (a *API) kbSetDefault(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.kbReady(w) {
|
||||
return
|
||||
}
|
||||
var in KBDefaultRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
scope, id, err := a.kbTarget(r.Context(), in.Target)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
d := store.KBDefault{Scope: scope, ScopeID: id, AddedBy: actorOf(r)}
|
||||
for _, ref := range in.Collections {
|
||||
c, err := a.kbFind(r.Context(), ref)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
d.Collections = append(d.Collections, c.ID)
|
||||
}
|
||||
if err := a.kb.Store.SetKBDefault(r.Context(), d); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, KBDefaultView{Target: string(scope) + ":" + id, Collections: in.Collections, At: time.Now(), By: actorOf(r)})
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
func kbModels() []catalog.Model {
|
||||
return append(testModels(), catalog.Model{Name: "bge-m3", Source: "/e",
|
||||
Capabilities: catalog.Capabilities{Embeddings: true},
|
||||
Profiles: []catalog.Profile{{Name: "cpu", GPUClass: "h100-80g", TP: 1}}})
|
||||
}
|
||||
|
||||
func TestKnowledgeBasesListSaysWhyThereAreNone(t *testing.T) {
|
||||
a, _ := setup(t, kbModels())
|
||||
s, err := store.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "s.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
a.SetKB(&KBAdmin{Store: s})
|
||||
var v KBView
|
||||
if code := call(t, a, "GET", "/api/v1/kb", "", &v); code != http.StatusOK || v.Available || !strings.Contains(v.Reason, "SQLite") {
|
||||
t.Errorf("got %d %+v", code, v)
|
||||
}
|
||||
if code := call(t, a, "POST", "/api/v1/kb/collections", `{"owner":"org:x","name":"n","model":"bge-m3"}`, nil); code != http.StatusNotImplemented {
|
||||
t.Errorf("creating on SQLite: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgeBasesAdministered(t *testing.T) {
|
||||
dsn := os.Getenv("LLMGW_TEST_POSTGRES")
|
||||
if dsn == "" {
|
||||
t.Skip("set LLMGW_TEST_POSTGRES to a pgvector database to run this")
|
||||
}
|
||||
if u, err := url.Parse(dsn); err != nil || !strings.Contains(u.Path, "test") {
|
||||
t.Fatalf("refusing a database whose name does not say test")
|
||||
}
|
||||
ctx := context.Background()
|
||||
s, err := store.OpenPostgres(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
if err := s.KnowledgeAvailable(); err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
a, _ := setup(t, kbModels())
|
||||
a.SetQuotas(s)
|
||||
probes := 0
|
||||
a.SetKB(&KBAdmin{Store: s, Embed: func(_ context.Context, model, _ string) ([]float32, error) {
|
||||
probes++
|
||||
return make([]float32, 4), nil
|
||||
}})
|
||||
sfx := strings.ToLower(store.NewID(""))
|
||||
org, _ := s.CreateOrg(ctx, "Acme"+sfx)
|
||||
team, _ := s.CreateTeam(ctx, org, "hr")
|
||||
_, _ = s.CreateProject(ctx, team, "docs")
|
||||
other, _ := s.CreateOrg(ctx, "Other"+sfx)
|
||||
|
||||
// Owner by path; the dimension asked of the model.
|
||||
var c KBCollectionView
|
||||
rec := func(method, path, body string, out any) int { return call(t, a, method, path, body, out) }
|
||||
body := `{"owner":"team:Acme` + sfx + `/hr","name":"politikalar","model":"bge-m3"}`
|
||||
if code := callCreated(t, a, body, &c); code != http.StatusCreated || c.Dims != 4 || probes != 1 || c.Org != org {
|
||||
t.Fatalf("create: %d %+v (probes %d)", code, c, probes)
|
||||
}
|
||||
// A model that does not embed is refused before anything is written.
|
||||
if code := rec("POST", "/api/v1/kb/collections", `{"owner":"org:`+org+`","name":"x","model":"llama"}`, nil); code != http.StatusBadRequest {
|
||||
t.Errorf("a chat model as the embedder: %d", code)
|
||||
}
|
||||
// Grants by path, never across organizations.
|
||||
if code := rec("POST", "/api/v1/kb/grants", `{"collection":"Acme`+sfx+`/politikalar","target":"project:Acme`+sfx+`/hr/docs","perm":"write"}`, nil); code != http.StatusOK {
|
||||
t.Errorf("grant: %d", code)
|
||||
}
|
||||
if code := rec("POST", "/api/v1/kb/grants", `{"collection":"`+c.ID+`","target":"org:`+other+`"}`, nil); code != http.StatusBadRequest {
|
||||
t.Errorf("a grant to another organization: %d", code)
|
||||
}
|
||||
var v KBView
|
||||
rec("GET", "/api/v1/kb", "", &v)
|
||||
var got *KBCollectionView
|
||||
for i := range v.Collections {
|
||||
if v.Collections[i].ID == c.ID {
|
||||
got = &v.Collections[i]
|
||||
}
|
||||
}
|
||||
if got == nil || len(got.Grants) != 1 || got.Grants[0].Perm != "write" || !strings.Contains(got.OwnerName, "hr") {
|
||||
t.Fatalf("list: %+v", got)
|
||||
}
|
||||
// By ORG/NAME too.
|
||||
if code := rec("DELETE", "/api/v1/kb/grants", `{"collection":"Acme`+sfx+`/politikalar","target":"project:Acme`+sfx+`/hr/docs"}`, nil); code != http.StatusOK {
|
||||
t.Errorf("revoke: %d", code)
|
||||
}
|
||||
if code := rec("DELETE", "/api/v1/kb/collections/"+c.ID, "", nil); code != http.StatusOK {
|
||||
t.Errorf("delete: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func callCreated(t *testing.T, a *API, body string, out any) int {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
a.Register(mux)
|
||||
r, _ := http.NewRequest("POST", "/api/v1/kb/collections", strings.NewReader(body))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
w := &recorder{h: http.Header{}}
|
||||
mux.ServeHTTP(w, r)
|
||||
if w.code == http.StatusCreated {
|
||||
_ = json.Unmarshal(w.body, out)
|
||||
} else {
|
||||
t.Logf("create answered %d %s", w.code, w.body)
|
||||
}
|
||||
return w.code
|
||||
}
|
||||
|
||||
type recorder struct {
|
||||
h http.Header
|
||||
code int
|
||||
body []byte
|
||||
}
|
||||
|
||||
func (r *recorder) Header() http.Header { return r.h }
|
||||
func (r *recorder) Write(b []byte) (int, error) {
|
||||
if r.code == 0 {
|
||||
r.code = http.StatusOK
|
||||
}
|
||||
r.body = append(r.body, b...)
|
||||
return len(b), nil
|
||||
}
|
||||
func (r *recorder) WriteHeader(c int) { r.code = c }
|
||||
@@ -0,0 +1,147 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Training data from a collection (BACKLOG F4): questions a person might
|
||||
// ask of each chunk and the answers it holds, written by a model of the
|
||||
// cluster, for people to read through before an adapter is trained on them.
|
||||
// What an adapter learns well is how the organisation's answers sound; what
|
||||
// they say is still the knowledge base's to give.
|
||||
|
||||
// KBDatasetRequest asks for training data from a collection.
|
||||
type KBDatasetRequest struct {
|
||||
// Model writes the questions and answers: a chat model of the catalog.
|
||||
Model string `json:"model"`
|
||||
// PerChunk is how many pairs each chunk gives; 8 when 0.
|
||||
PerChunk int `json:"per_chunk,omitempty"`
|
||||
// MaxChunks bounds how many chunks are read; 500 when 0.
|
||||
MaxChunks int `json:"max_chunks,omitempty"`
|
||||
}
|
||||
|
||||
// KBDatasetLine is one line of the answer: a pair, or why a chunk gave none.
|
||||
type KBDatasetLine struct {
|
||||
Q string `json:"q,omitempty"`
|
||||
A string `json:"a,omitempty"`
|
||||
Doc string `json:"doc"`
|
||||
Ord int `json:"ord"`
|
||||
// Collection is where the pair came from: an adapter trained on it
|
||||
// belongs to a part of the tree that may read it.
|
||||
Collection string `json:"collection,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
const kbDatasetPrompt = `From the passage of an organisation's document below, write %d different questions an employee might ask, each with a short, correct answer.
|
||||
Rules: use only what the passage says. Phrase the questions differently from each other (formal, everyday, short, indirect). Answers are one or two sentences and keep the passage's numbers exactly. Leave out e-mail addresses and people's names. Write in the passage's own language.
|
||||
Output: one JSON object per line, {"q": "...", "a": "..."}, and nothing else.
|
||||
|
||||
DOCUMENT %s:
|
||||
%s`
|
||||
|
||||
var thinkBlock = regexp.MustCompile(`(?s)<think>.*?</think>`)
|
||||
|
||||
func (a *API) kbDataset(w http.ResponseWriter, r *http.Request) {
|
||||
c, ok := a.kbCollection(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if a.replay == nil {
|
||||
writeError(w, http.StatusNotImplemented, "no router here to ask a model")
|
||||
return
|
||||
}
|
||||
var in KBDatasetRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
m, found := a.ctl.Catalog().Resolve(in.Model)
|
||||
if !found {
|
||||
writeError(w, http.StatusBadRequest, "no model "+in.Model+" in the catalog")
|
||||
return
|
||||
}
|
||||
if m.Capabilities.Embeddings {
|
||||
writeError(w, http.StatusBadRequest, m.Name+" embeds; questions are written by a chat model")
|
||||
return
|
||||
}
|
||||
per := in.PerChunk
|
||||
if per <= 0 || per > 30 {
|
||||
per = 8
|
||||
}
|
||||
limit := in.MaxChunks
|
||||
if limit <= 0 || limit > 5000 {
|
||||
limit = 500
|
||||
}
|
||||
chunks, err := a.kb.Store.CollectionChunks(r.Context(), c, limit)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
enc := json.NewEncoder(w)
|
||||
flush := func() {
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
for _, ch := range chunks {
|
||||
if r.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{"model": m.Name, "temperature": 0.7, "max_tokens": 200 * per,
|
||||
"messages": []map[string]string{{"role": "user", "content": fmt.Sprintf(kbDatasetPrompt, per, ch.Document, ch.Body)}}})
|
||||
status, out, _, err := a.replay(r.Context(), "/v1/chat/completions", m.Name, body)
|
||||
line := KBDatasetLine{Doc: ch.Document, Ord: ch.Ord, Collection: c.ID}
|
||||
if err == nil && status != http.StatusOK {
|
||||
err = fmt.Errorf("%d: %s", status, strings.TrimSpace(string(out)))
|
||||
}
|
||||
if err != nil {
|
||||
line.Error = err.Error()
|
||||
_ = enc.Encode(line)
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
pairs := datasetPairs(out)
|
||||
if len(pairs) == 0 {
|
||||
line.Error = "the model gave no pairs it could read"
|
||||
_ = enc.Encode(line)
|
||||
}
|
||||
for _, p := range pairs {
|
||||
p.Doc, p.Ord, p.Collection = ch.Document, ch.Ord, c.ID
|
||||
_ = enc.Encode(p)
|
||||
}
|
||||
flush()
|
||||
}
|
||||
}
|
||||
|
||||
// datasetPairs reads the {"q","a"} lines out of a chat completion.
|
||||
func datasetPairs(completion []byte) []KBDatasetLine {
|
||||
var res struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if json.Unmarshal(completion, &res) != nil || len(res.Choices) == 0 {
|
||||
return nil
|
||||
}
|
||||
text := thinkBlock.ReplaceAllString(res.Choices[0].Message.Content, "")
|
||||
var out []KBDatasetLine
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
line = strings.TrimSuffix(strings.TrimSpace(line), ",")
|
||||
if !strings.HasPrefix(line, "{") || !strings.HasSuffix(line, "}") {
|
||||
continue
|
||||
}
|
||||
var p struct{ Q, A string }
|
||||
if json.Unmarshal([]byte(line), &p) == nil && strings.TrimSpace(p.Q) != "" && strings.TrimSpace(p.A) != "" {
|
||||
out = append(out, KBDatasetLine{Q: strings.TrimSpace(p.Q), A: strings.TrimSpace(p.A)})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
func TestPairsAreReadOutOfWhatAModelWrote(t *testing.T) {
|
||||
completion := `{"choices":[{"message":{"content":"<think>let me see</think>\n{\"q\": \"Babalık izni kaç gün?\", \"a\": \"10 iş günü.\"},\nnot json\n{\"q\": \"\", \"a\": \"empty question\"}\n{\"q\": \"Ne zaman?\", \"a\": \"Doğumdan sonra.\"}"}}]}`
|
||||
got := datasetPairs([]byte(completion))
|
||||
if len(got) != 2 || got[0].Q != "Babalık izni kaç gün?" || got[1].A != "Doğumdan sonra." {
|
||||
t.Errorf("pairs %+v", got)
|
||||
}
|
||||
if datasetPairs([]byte(`{"choices":[]}`)) != nil {
|
||||
t.Error("pairs from no choice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestADatasetIsWrittenChunkByChunk(t *testing.T) {
|
||||
dsn := os.Getenv("LLMGW_TEST_POSTGRES")
|
||||
if dsn == "" {
|
||||
t.Skip("set LLMGW_TEST_POSTGRES to a pgvector database to run this")
|
||||
}
|
||||
ctx := context.Background()
|
||||
s, err := store.OpenPostgres(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
if s.KnowledgeAvailable() != nil {
|
||||
t.Skip("no pgvector")
|
||||
}
|
||||
models := append(kbModels(), catalog.Model{Name: "writer", Source: "/w",
|
||||
Profiles: []catalog.Profile{{Name: "cpu", GPUClass: "h100-80g", TP: 1}}})
|
||||
a, _ := setup(t, models)
|
||||
a.SetKB(&KBAdmin{Store: s})
|
||||
asked := 0
|
||||
a.SetReplayer(func(_ context.Context, path, model string, body []byte) (int, []byte, time.Duration, error) {
|
||||
asked++
|
||||
if path != "/v1/chat/completions" || model != "writer" || !strings.Contains(string(body), "Write in the passage") {
|
||||
t.Errorf("asked %s %s %s", path, model, body)
|
||||
}
|
||||
if strings.Contains(string(body), "bozuk") {
|
||||
return 200, []byte(`{"choices":[{"message":{"content":"sorry"}}]}`), 0, nil
|
||||
}
|
||||
return 200, []byte(`{"choices":[{"message":{"content":"{\"q\":\"Soru?\",\"a\":\"Cevap.\"}"}}]}`), 0, nil
|
||||
})
|
||||
org, _ := s.CreateOrg(ctx, "ds-"+strings.ToLower(store.NewID("")))
|
||||
c, err := s.CreateCollection(ctx, store.KBCollection{OwnerScope: store.ScopeOrg, OwnerID: org, Name: "d", EmbedModel: "bge-m3", Dims: 3})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, d := range []string{"a.md", "b.md"} {
|
||||
body := "iyi metin"
|
||||
if d == "b.md" {
|
||||
body = "bozuk metin"
|
||||
}
|
||||
if _, _, err := s.PutDocument(ctx, c, store.KBDocument{Name: d, Digest: d}, []store.KBChunk{{Body: body, Embedding: []float32{1, 0, 0}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
a.Register(mux)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest("POST", "/api/v1/kb/collections/"+c.ID+"/dataset", strings.NewReader(`{"model":"writer","per_chunk":3}`)))
|
||||
var lines []KBDatasetLine
|
||||
for _, l := range strings.Split(strings.TrimSpace(rec.Body.String()), "\n") {
|
||||
var x KBDatasetLine
|
||||
_ = json.Unmarshal([]byte(l), &x)
|
||||
lines = append(lines, x)
|
||||
}
|
||||
if rec.Code != 200 || asked != 2 || len(lines) != 2 || lines[0].Q != "Soru?" || lines[0].Doc != "a.md" ||
|
||||
lines[1].Doc != "b.md" || lines[1].Error == "" {
|
||||
t.Errorf("%d asked %d: %+v", rec.Code, asked, lines)
|
||||
}
|
||||
// An embedding model writes no questions.
|
||||
rec = httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest("POST", "/api/v1/kb/collections/"+c.ID+"/dataset", strings.NewReader(`{"model":"bge-m3"}`)))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("an embedding model: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,13 @@ var PathParams = map[string]string{
|
||||
"quotas/{scope}": "The scope: org:NAME, team:ORG/TEAM, project:ORG/TEAM/PROJECT, or their IDs after the colon.",
|
||||
"federation/{peer}": "The federation peer's name, as `llmgw ctl federation list` lists it.",
|
||||
"holds/{domain}": "The domain as level:name, rack:r12-07, as `llmgw ctl facility` lists it.",
|
||||
"collections/{id}": "The collection's ID, its name when that is unique, or ORG/NAME.",
|
||||
"orgs/{id}": "The organization's ID or name.",
|
||||
"teams/{id}": "The team's ID.",
|
||||
"projects/{id}": "The project's ID.",
|
||||
"keys/{id}": "The key's ID, as listed (not the key itself).",
|
||||
"users/{id}": "The person's ID or email.",
|
||||
"artifacts/{kind}": "What the job made: peft, a gzipped tar of the PEFT adapter, or gguf, the adapter for llama.cpp.",
|
||||
}
|
||||
|
||||
// Routes is every endpoint Register mounts.
|
||||
@@ -263,6 +270,101 @@ var Routes = []Route{
|
||||
{Method: "DELETE", Path: "/api/v1/guard/attachments", Summary: "Detach a guardrail policy",
|
||||
Request: GuardAttachmentRequest{}, Response: guard.Attachment{}},
|
||||
|
||||
// Knowledge bases: collections of documents, which people fill with
|
||||
// llmgw-kb from their own machines through the gateway.
|
||||
{Method: "GET", Path: "/api/v1/kb", Summary: "Every knowledge-base collection, with its documents' count and grants",
|
||||
Description: "Says why there are none when the store cannot hold them (SQLite, or PostgreSQL without pgvector).",
|
||||
Response: KBView{}},
|
||||
{Method: "POST", Path: "/api/v1/kb/collections", Summary: "Make a collection, owned by an organization, team, project or person",
|
||||
Description: "The embedding model's dimension is asked of the model when dims is not given, so the model must be able to serve.",
|
||||
Request: KBCreateRequest{}, Response: KBCollectionView{}, Status: http.StatusCreated},
|
||||
{Method: "DELETE", Path: "/api/v1/kb/collections/{id}", Summary: "Remove a collection with its documents and grants",
|
||||
Response: map[string]any{}},
|
||||
{Method: "GET", Path: "/api/v1/kb/collections/{id}/documents", Summary: "A collection's documents",
|
||||
Response: []KBDocumentView{}},
|
||||
{Method: "DELETE", Path: "/api/v1/kb/collections/{id}/documents", Summary: "Remove one document from a collection",
|
||||
Query: []Param{{Name: "name", Description: "The document's name, as listed.", Required: true}},
|
||||
Response: map[string]any{}},
|
||||
{Method: "POST", Path: "/api/v1/kb/grants", Summary: "Let a scope, and everything below it, read or write a collection",
|
||||
Description: "Never outside the collection's organization.",
|
||||
Request: KBGrantRequest{}, Response: KBGrantView{}},
|
||||
{Method: "DELETE", Path: "/api/v1/kb/grants", Summary: "Take a grant away",
|
||||
Request: KBGrantRequest{}, Response: KBGrantView{}},
|
||||
{Method: "POST", Path: "/api/v1/kb/defaults", Summary: "Set the collections a part of the tree answers from when a request names none",
|
||||
Description: "On an organization, team, project or key; the narrowest one with a default wins. No collections clears it. " +
|
||||
"A request's own X-Llmgw-Knowledge overrides it, and X-Llmgw-Knowledge: none turns it off.",
|
||||
Request: KBDefaultRequest{}, Response: KBDefaultView{}},
|
||||
{Method: "POST", Path: "/api/v1/kb/collections/{id}/dataset", Summary: "Training data from a collection: questions and answers a chat model writes from each chunk",
|
||||
Description: "NDJSON, a line per pair as each chunk is read, or a line with error for a chunk that gave none. For people to correct before an adapter is trained on it.",
|
||||
Request: KBDatasetRequest{}, Response: KBDatasetLine{}},
|
||||
{Method: "POST", Path: "/api/v1/kb/search", Summary: "Try a search of a collection, as an administrator",
|
||||
Description: "No tenancy applies: this is for checking what a collection holds and finds.",
|
||||
Request: KBSearchRequest{}, Response: []KBHitView{}},
|
||||
|
||||
// LoRA adapters trained on the nodes.
|
||||
// Organizations, teams, projects, keys and people.
|
||||
{Method: "GET", Path: "/api/v1/orgs", Summary: "The tenancy tree: every organization, its teams and their projects",
|
||||
Response: []OrgView{}},
|
||||
{Method: "POST", Path: "/api/v1/orgs", Summary: "Add an organization",
|
||||
Request: TenancyCreateRequest{}, Response: TenancyItem{}, Status: http.StatusCreated},
|
||||
{Method: "PATCH", Path: "/api/v1/orgs/{id}", Summary: "Rename an organization",
|
||||
Request: TenancyChangeRequest{}, Response: TenancyItem{}},
|
||||
{Method: "DELETE", Path: "/api/v1/orgs/{id}", Summary: "Remove an organization that has no teams",
|
||||
Response: map[string]any{}},
|
||||
{Method: "POST", Path: "/api/v1/teams", Summary: "Add a team to an organization",
|
||||
Request: TenancyCreateRequest{}, Response: TenancyItem{}, Status: http.StatusCreated},
|
||||
{Method: "PATCH", Path: "/api/v1/teams/{id}", Summary: "Rename a team, or move it to another organization",
|
||||
Description: "Everything under it moves too, and from then on inherits the new organization's limits.",
|
||||
Request: TenancyChangeRequest{}, Response: TenancyItem{}},
|
||||
{Method: "DELETE", Path: "/api/v1/teams/{id}", Summary: "Remove a team that has no projects",
|
||||
Response: map[string]any{}},
|
||||
{Method: "POST", Path: "/api/v1/projects", Summary: "Add a project to a team",
|
||||
Request: TenancyCreateRequest{}, Response: TenancyItem{}, Status: http.StatusCreated},
|
||||
{Method: "PATCH", Path: "/api/v1/projects/{id}", Summary: "Rename a project, or move it to another team",
|
||||
Request: TenancyChangeRequest{}, Response: TenancyItem{}},
|
||||
{Method: "DELETE", Path: "/api/v1/projects/{id}", Summary: "Remove a project with no people, keys or collections",
|
||||
Description: "A project that ever had a key keeps it, revoked, because usage refers to it; rename such a project instead.",
|
||||
Response: map[string]any{}},
|
||||
{Method: "GET", Path: "/api/v1/keys", Summary: "API keys, with their project and owner; never the keys themselves",
|
||||
Query: []Param{{Name: "project", Description: "Only this project's keys: ORG/TEAM/PROJECT or its ID."}},
|
||||
Response: []KeyView{}},
|
||||
{Method: "POST", Path: "/api/v1/keys", Summary: "Issue an API key",
|
||||
Description: "The answer holds the key, once: only its hash is kept. A lifetime longer than a scope above it allows is refused.",
|
||||
Request: KeyCreateRequest{}, Response: KeyCreated{}, Status: http.StatusCreated},
|
||||
{Method: "GET", Path: "/api/v1/keys/{id}", Summary: "One API key", Response: KeyView{}},
|
||||
{Method: "DELETE", Path: "/api/v1/keys/{id}", Summary: "Revoke an API key",
|
||||
Description: "A soft delete: usage refers to it. A gateway that has it cached accepts it until auth.cache_ttl.",
|
||||
Response: map[string]any{}},
|
||||
{Method: "GET", Path: "/api/v1/users", Summary: "People, with where each is a member",
|
||||
Query: []Param{{Name: "state", Description: "Only people in this state: pending, active or disabled."}},
|
||||
Response: []UserView{}},
|
||||
{Method: "GET", Path: "/api/v1/users/{id}", Summary: "One person, with where they are a member", Response: UserView{}},
|
||||
{Method: "POST", Path: "/api/v1/users/{id}/memberships", Summary: "Put a person in an organization, team or project",
|
||||
Description: "Already there, only the role changes.",
|
||||
Request: MembershipRequest{}, Response: UserView{}},
|
||||
{Method: "DELETE", Path: "/api/v1/users/{id}/memberships", Summary: "Take a person out of an organization, team or project",
|
||||
Description: "Their keys in projects they no longer reach are revoked.",
|
||||
Request: MembershipRequest{}, Response: UserView{}},
|
||||
|
||||
{Method: "POST", Path: "/api/v1/train/jobs", Summary: "Queue a LoRA adapter's training on a node",
|
||||
Description: "The node trains when its agent is there, nothing else trains on it, and a card has gpu_memory_mib free; " +
|
||||
"the pairs are NDJSON of {\"q\", \"a\"}. An adapter learns how answers sound far better than what they say.",
|
||||
Request: TrainRequest{}, Response: TrainJobView{}, Status: http.StatusCreated},
|
||||
{Method: "GET", Path: "/api/v1/train/jobs", Summary: "Training jobs, newest first",
|
||||
Query: []Param{{Name: "limit", Description: "How many; 50 when absent.", Type: "integer"}},
|
||||
Response: []TrainJobView{}},
|
||||
{Method: "GET", Path: "/api/v1/train/jobs/{id}", Summary: "One training job: how it goes, and what it made",
|
||||
Response: TrainJobView{}},
|
||||
{Method: "POST", Path: "/api/v1/train/jobs/{id}/cancel", Summary: "Stop a training job",
|
||||
Response: TrainJobView{}},
|
||||
{Method: "DELETE", Path: "/api/v1/train/jobs/{id}", Summary: "Remove an ended training job and what it made",
|
||||
Response: map[string]any{}},
|
||||
{Method: "POST", Path: "/api/v1/train/jobs/{id}/publish", Summary: "Make what a finished job made the model's adapter",
|
||||
Description: "Kept on the mirror — the GGUF for a model llama.cpp serves, the PEFT files for one vLLM serves — and added to the model's adapters after the same check as POST /api/v1/models/{name}/adapters.",
|
||||
Response: TrainPublishView{}},
|
||||
{Method: "GET", Path: "/api/v1/train/jobs/{id}/artifacts/{kind}", Summary: "What a training job made: peft (a gzipped tar) or gguf",
|
||||
Response: map[string]any{}},
|
||||
|
||||
{Method: "POST", Path: "/api/v1/agents/restart", Summary: "Have every node's agent start again; their engines keep serving",
|
||||
Response: AgentRestartView{}},
|
||||
|
||||
|
||||
@@ -0,0 +1,658 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
// The tenancy tree, keys and people's memberships, administered over the
|
||||
// API as `llmgw org`, `keys` and `users` do against the store: so that
|
||||
// they can be run from elsewhere, and kept in Terraform.
|
||||
|
||||
// TenancyStore is the part of the store the tenancy endpoints need.
|
||||
type TenancyStore interface {
|
||||
Tree(ctx context.Context) ([]store.TreeOrg, error)
|
||||
CreateOrg(ctx context.Context, name string) (string, error)
|
||||
CreateTeam(ctx context.Context, orgID, name string) (string, error)
|
||||
CreateProject(ctx context.Context, teamID, name string) (string, error)
|
||||
RenameOrg(ctx context.Context, id, name string) error
|
||||
RenameTeam(ctx context.Context, id, name string) error
|
||||
RenameProject(ctx context.Context, id, name string) error
|
||||
MoveTeam(ctx context.Context, teamID, orgID string) error
|
||||
MoveProject(ctx context.Context, projectID, teamID string) error
|
||||
DeleteOrg(ctx context.Context, id string) error
|
||||
DeleteTeam(ctx context.Context, id string) error
|
||||
DeleteProject(ctx context.Context, id string) error
|
||||
|
||||
AllKeys(ctx context.Context) ([]store.KeyRow, error)
|
||||
KeyByID(ctx context.Context, id string) (store.Key, error)
|
||||
CreateKey(ctx context.Context, k store.Key) error
|
||||
CreateUserKey(ctx context.Context, userID string, k store.Key) error
|
||||
RevokeKey(ctx context.Context, id string, at time.Time) error
|
||||
|
||||
Users(ctx context.Context, state store.UserState) ([]store.User, error)
|
||||
UserByID(ctx context.Context, id string) (store.User, error)
|
||||
UserByEmail(ctx context.Context, email string) (store.User, error)
|
||||
MembershipsOf(ctx context.Context, userID string) ([]store.Membership, error)
|
||||
AddMembership(ctx context.Context, userID string, scope store.Scope, scopeID, role, by string, at time.Time) error
|
||||
RemoveMembership(ctx context.Context, userID string, scope store.Scope, scopeID string, at time.Time) (int, error)
|
||||
}
|
||||
|
||||
// SetTenancy enables the tenancy endpoints. The quota store must be set
|
||||
// too: names are resolved through it.
|
||||
func (a *API) SetTenancy(t TenancyStore) { a.tenancy = t }
|
||||
|
||||
// OrgView is an organization with what is in it.
|
||||
type OrgView struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Members int `json:"members"`
|
||||
Teams []TeamView `json:"teams"`
|
||||
}
|
||||
|
||||
// TeamView is a team with its projects.
|
||||
type TeamView struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Org string `json:"org"`
|
||||
Members int `json:"members"`
|
||||
Projects []ProjectView `json:"projects"`
|
||||
}
|
||||
|
||||
// ProjectView is a project, with how many people and keys are in it.
|
||||
type ProjectView struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Team string `json:"team"`
|
||||
// Path is ORG/TEAM/PROJECT by name.
|
||||
Path string `json:"path"`
|
||||
Users int `json:"users"`
|
||||
Keys int `json:"keys"`
|
||||
ActiveKeys int `json:"active_keys"`
|
||||
}
|
||||
|
||||
// TenancyCreateRequest adds an organization, a team or a project.
|
||||
type TenancyCreateRequest struct {
|
||||
Name string `json:"name"`
|
||||
// In is the organization a team goes in, or the team a project goes in
|
||||
// (ORG/TEAM), by name or ID; nothing for an organization.
|
||||
In string `json:"in,omitempty"`
|
||||
}
|
||||
|
||||
// TenancyChangeRequest renames an organization, team or project, or moves
|
||||
// a team to another organization or a project to another team.
|
||||
type TenancyChangeRequest struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
// In is where it moves to, by name or ID; not for an organization.
|
||||
In string `json:"in,omitempty"`
|
||||
}
|
||||
|
||||
// TenancyItem is an organization, team or project added or changed.
|
||||
type TenancyItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
// In is the organization a team is in, or the team a project is in, by
|
||||
// ID.
|
||||
In string `json:"in,omitempty"`
|
||||
}
|
||||
|
||||
// KeyView is a key as listed: never the key itself, which is not kept.
|
||||
type KeyView struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
// Prefix is the key's first characters, to tell it apart.
|
||||
Prefix string `json:"prefix"`
|
||||
Project string `json:"project,omitempty"`
|
||||
// ProjectPath is ORG/TEAM/PROJECT, or empty for a personal key.
|
||||
ProjectPath string `json:"project_path,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
UserEmail string `json:"user_email,omitempty"`
|
||||
Band string `json:"band,omitempty"`
|
||||
RoutingProfile string `json:"routing_profile,omitempty"`
|
||||
State string `json:"state"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at,omitzero"`
|
||||
RevokedAt time.Time `json:"revoked_at,omitzero"`
|
||||
LastUsedAt time.Time `json:"last_used_at,omitzero"`
|
||||
}
|
||||
|
||||
// KeyCreateRequest issues a key: a project's, a person's in a project they
|
||||
// are a member of, or a person's own with no project.
|
||||
type KeyCreateRequest struct {
|
||||
Name string `json:"name"`
|
||||
// Project is ORG/TEAM/PROJECT or its ID.
|
||||
Project string `json:"project,omitempty"`
|
||||
// User is the person it belongs to, by ID or email.
|
||||
User string `json:"user,omitempty"`
|
||||
Band string `json:"band,omitempty"`
|
||||
RoutingProfile string `json:"routing_profile,omitempty"`
|
||||
// ExpiresIn is its lifetime, as 720h; none never expires, unless a
|
||||
// scope above it limits how long keys live.
|
||||
ExpiresIn string `json:"expires_in,omitempty"`
|
||||
}
|
||||
|
||||
// KeyCreated is a new key, with the key itself: this answer is the only
|
||||
// time it is given.
|
||||
type KeyCreated struct {
|
||||
KeyView
|
||||
Secret string `json:"secret"`
|
||||
}
|
||||
|
||||
// UserView is a person, with where they are a member.
|
||||
type UserView struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name,omitempty"`
|
||||
State string `json:"state"`
|
||||
// Role is the person's part in running the gateway, if any: viewer,
|
||||
// operator, billing or admin.
|
||||
Role string `json:"role,omitempty"`
|
||||
Memberships []MembershipView `json:"memberships"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastLoginAt time.Time `json:"last_login_at,omitzero"`
|
||||
}
|
||||
|
||||
// MembershipView is one place a person belongs.
|
||||
type MembershipView struct {
|
||||
Target string `json:"target"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Role string `json:"role"`
|
||||
At time.Time `json:"at"`
|
||||
By string `json:"by,omitempty"`
|
||||
}
|
||||
|
||||
// MembershipRequest puts a person in an organization, team or project, or
|
||||
// with DELETE takes them out.
|
||||
type MembershipRequest struct {
|
||||
// Target is org:NAME, team:ORG/TEAM or project:ORG/TEAM/PROJECT, or the
|
||||
// ID after the colon.
|
||||
Target string `json:"target"`
|
||||
// Role is member or manager; member when absent. Ignored by DELETE.
|
||||
Role string `json:"role,omitempty"`
|
||||
}
|
||||
|
||||
func (a *API) registerTenancy(mux Mux) {
|
||||
mux.HandleFunc("GET /api/v1/orgs", a.tenancyTree)
|
||||
for _, level := range []store.Scope{store.ScopeOrg, store.ScopeTeam, store.ScopeProject} {
|
||||
path := "/api/v1/" + string(level) + "s"
|
||||
mux.HandleFunc("POST "+path, a.tenancyCreate(level))
|
||||
mux.HandleFunc("PATCH "+path+"/{id}", a.tenancyChange(level))
|
||||
mux.HandleFunc("DELETE "+path+"/{id}", a.tenancyDelete(level))
|
||||
}
|
||||
mux.HandleFunc("GET /api/v1/keys", a.keyList)
|
||||
mux.HandleFunc("POST /api/v1/keys", a.keyCreate)
|
||||
mux.HandleFunc("GET /api/v1/keys/{id}", a.keyGet)
|
||||
mux.HandleFunc("DELETE /api/v1/keys/{id}", a.keyRevoke)
|
||||
mux.HandleFunc("GET /api/v1/users", a.userList)
|
||||
mux.HandleFunc("GET /api/v1/users/{id}", a.userGet)
|
||||
mux.HandleFunc("POST /api/v1/users/{id}/memberships", a.membership(true))
|
||||
mux.HandleFunc("DELETE /api/v1/users/{id}/memberships", a.membership(false))
|
||||
}
|
||||
|
||||
// tenancyReady answers for a control plane without the store, and says
|
||||
// whether it did.
|
||||
func (a *API) tenancyReady(w http.ResponseWriter) bool {
|
||||
if a.tenancy == nil || a.quotas == nil {
|
||||
writeError(w, http.StatusNotImplemented, "organizations, keys and people need the store, which this control plane runs without")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// tenancyStatus is the status a store error answers with.
|
||||
func tenancyStatus(err error) int {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
return http.StatusNotFound
|
||||
case errors.Is(err, store.ErrDuplicateName), errors.Is(err, store.ErrNotEmpty):
|
||||
return http.StatusConflict
|
||||
}
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
|
||||
func (a *API) tenancyTree(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.tenancyReady(w) {
|
||||
return
|
||||
}
|
||||
tree, err := a.tenancy.Tree(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
out := []OrgView{}
|
||||
for _, o := range tree {
|
||||
ov := OrgView{ID: o.ID, Name: o.Name, Members: o.Members, Teams: []TeamView{}}
|
||||
for _, t := range o.Teams {
|
||||
tv := TeamView{ID: t.ID, Name: t.Name, Org: o.ID, Members: t.Members, Projects: []ProjectView{}}
|
||||
for _, p := range t.Projects {
|
||||
tv.Projects = append(tv.Projects, ProjectView{ID: p.ID, Name: p.Name, Team: t.ID,
|
||||
Path: o.Name + "/" + t.Name + "/" + p.Name, Users: p.Users, Keys: p.Keys, ActiveKeys: p.ActiveKeys})
|
||||
}
|
||||
ov.Teams = append(ov.Teams, tv)
|
||||
}
|
||||
out = append(out, ov)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// parentOf is the level something at level sits in.
|
||||
func parentOf(level store.Scope) store.Scope {
|
||||
if level == store.ScopeProject {
|
||||
return store.ScopeTeam
|
||||
}
|
||||
return store.ScopeOrg
|
||||
}
|
||||
|
||||
// levelNoun is a level as a sentence says it.
|
||||
func levelNoun(level store.Scope) string {
|
||||
switch level {
|
||||
case store.ScopeOrg:
|
||||
return "an organization"
|
||||
case store.ScopeTeam:
|
||||
return "a team"
|
||||
}
|
||||
return "a project"
|
||||
}
|
||||
|
||||
func (a *API) tenancyCreate(level store.Scope) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.tenancyReady(w) {
|
||||
return
|
||||
}
|
||||
var in TenancyCreateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
if in.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "a name is needed")
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
var id, parent string
|
||||
var err error
|
||||
if level != store.ScopeOrg {
|
||||
if in.In == "" {
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("%s goes in %s: give in", levelNoun(level), levelNoun(parentOf(level))))
|
||||
return
|
||||
}
|
||||
if _, parent, _, err = a.resolveScope(ctx, string(parentOf(level))+":"+in.In); err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
switch level {
|
||||
case store.ScopeOrg:
|
||||
id, err = a.tenancy.CreateOrg(ctx, in.Name)
|
||||
case store.ScopeTeam:
|
||||
id, err = a.tenancy.CreateTeam(ctx, parent, in.Name)
|
||||
default:
|
||||
id, err = a.tenancy.CreateProject(ctx, parent, in.Name)
|
||||
}
|
||||
if errors.Is(err, store.ErrDuplicateName) {
|
||||
writeError(w, http.StatusConflict, fmt.Sprintf("there is already %s named %q there", levelNoun(level), in.Name))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, tenancyStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, TenancyItem{ID: id, Name: in.Name, In: parent})
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) tenancyChange(level store.Scope) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.tenancyReady(w) {
|
||||
return
|
||||
}
|
||||
var in TenancyChangeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
_, id, _, err := a.resolveScope(ctx, string(level)+":"+r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if in.Name == "" && in.In == "" {
|
||||
writeError(w, http.StatusBadRequest, "nothing to change: give name, in or both")
|
||||
return
|
||||
}
|
||||
out := TenancyItem{ID: id}
|
||||
if in.In != "" {
|
||||
if level == store.ScopeOrg {
|
||||
writeError(w, http.StatusBadRequest, "an organization is in nothing; only a team or a project moves")
|
||||
return
|
||||
}
|
||||
_, to, _, err := a.resolveScope(ctx, string(parentOf(level))+":"+in.In)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if level == store.ScopeTeam {
|
||||
err = a.tenancy.MoveTeam(ctx, id, to)
|
||||
} else {
|
||||
err = a.tenancy.MoveProject(ctx, id, to)
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, tenancyStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
out.In = to
|
||||
}
|
||||
if name := strings.TrimSpace(in.Name); name != "" {
|
||||
switch level {
|
||||
case store.ScopeOrg:
|
||||
err = a.tenancy.RenameOrg(ctx, id, name)
|
||||
case store.ScopeTeam:
|
||||
err = a.tenancy.RenameTeam(ctx, id, name)
|
||||
default:
|
||||
err = a.tenancy.RenameProject(ctx, id, name)
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, tenancyStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
out.Name = name
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) tenancyDelete(level store.Scope) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.tenancyReady(w) {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
_, id, _, err := a.resolveScope(ctx, string(level)+":"+r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
switch level {
|
||||
case store.ScopeOrg:
|
||||
err = a.tenancy.DeleteOrg(ctx, id)
|
||||
case store.ScopeTeam:
|
||||
err = a.tenancy.DeleteTeam(ctx, id)
|
||||
default:
|
||||
err = a.tenancy.DeleteProject(ctx, id)
|
||||
}
|
||||
if err != nil {
|
||||
// Only what is empty goes: the store says what is still in it.
|
||||
writeError(w, tenancyStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"id": id, "deleted": true})
|
||||
}
|
||||
}
|
||||
|
||||
// keyState is a key's state at an instant.
|
||||
func keyState(k store.Key, now time.Time) string {
|
||||
switch {
|
||||
case !k.RevokedAt.IsZero():
|
||||
return "revoked"
|
||||
case !k.ExpiresAt.IsZero() && !now.Before(k.ExpiresAt):
|
||||
return "expired"
|
||||
}
|
||||
return "active"
|
||||
}
|
||||
|
||||
func keyView(k store.Key, path, email string, now time.Time) KeyView {
|
||||
// The store says "Acme / ml / search"; a path given to the API is
|
||||
// written Acme/ml/search, and is answered the same way.
|
||||
path = strings.ReplaceAll(path, " / ", "/")
|
||||
return KeyView{ID: k.ID, Name: k.Name, Prefix: k.Prefix, Project: k.ProjectID, ProjectPath: path,
|
||||
User: k.UserID, UserEmail: email, Band: k.Band, RoutingProfile: k.RoutingProfile, State: keyState(k, now),
|
||||
CreatedAt: k.CreatedAt, ExpiresAt: k.ExpiresAt, RevokedAt: k.RevokedAt, LastUsedAt: k.LastUsedAt}
|
||||
}
|
||||
|
||||
func (a *API) keyList(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.tenancyReady(w) {
|
||||
return
|
||||
}
|
||||
project := ""
|
||||
if p := r.URL.Query().Get("project"); p != "" {
|
||||
var err error
|
||||
if _, project, _, err = a.resolveScope(r.Context(), "project:"+p); err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
rows, err := a.tenancy.AllKeys(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
out := []KeyView{}
|
||||
for _, k := range rows {
|
||||
if project == "" || k.ProjectID == project {
|
||||
out = append(out, keyView(k.Key, k.ProjectPath, k.OwnerEmail, now))
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (a *API) keyGet(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.tenancyReady(w) {
|
||||
return
|
||||
}
|
||||
rows, err := a.tenancy.AllKeys(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
for _, k := range rows {
|
||||
if k.ID == r.PathValue("id") {
|
||||
writeJSON(w, http.StatusOK, keyView(k.Key, k.ProjectPath, k.OwnerEmail, time.Now()))
|
||||
return
|
||||
}
|
||||
}
|
||||
writeError(w, http.StatusNotFound, "no key "+r.PathValue("id"))
|
||||
}
|
||||
|
||||
// findUser reads a person by ID or email.
|
||||
func (a *API) findUser(ctx context.Context, ref string) (store.User, error) {
|
||||
ref = strings.TrimSpace(ref)
|
||||
if u, err := a.tenancy.UserByID(ctx, ref); err == nil {
|
||||
return u, nil
|
||||
}
|
||||
if strings.Contains(ref, "@") {
|
||||
if u, err := a.tenancy.UserByEmail(ctx, ref); err == nil {
|
||||
return u, nil
|
||||
}
|
||||
}
|
||||
return store.User{}, fmt.Errorf("%w: no person %q", store.ErrNotFound, ref)
|
||||
}
|
||||
|
||||
func (a *API) keyCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.tenancyReady(w) {
|
||||
return
|
||||
}
|
||||
var in KeyCreateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
if strings.TrimSpace(in.Name) == "" {
|
||||
writeError(w, http.StatusBadRequest, "a key needs a name")
|
||||
return
|
||||
}
|
||||
if in.Project == "" && in.User == "" {
|
||||
writeError(w, http.StatusBadRequest, "a key belongs to a project, a person, or a person in a project: give project, user or both")
|
||||
return
|
||||
}
|
||||
var owner store.User
|
||||
var err error
|
||||
if in.User != "" {
|
||||
if owner, err = a.findUser(ctx, in.User); err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
project, path := "", ""
|
||||
if in.Project != "" {
|
||||
if _, project, path, err = a.resolveScope(ctx, "project:"+in.Project); err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
var expires time.Duration
|
||||
if in.ExpiresIn != "" {
|
||||
if expires, err = time.ParseDuration(in.ExpiresIn); err != nil || expires <= 0 {
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("expires_in %q is not a positive duration such as 720h", in.ExpiresIn))
|
||||
return
|
||||
}
|
||||
}
|
||||
secret, k, err := store.NewKey(store.NewID("key-"), strings.TrimSpace(in.Name), project)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
k.CreatedAt = time.Now()
|
||||
k.Band, k.RoutingProfile = in.Band, in.RoutingProfile
|
||||
if expires > 0 {
|
||||
k.ExpiresAt = k.CreatedAt.Add(expires)
|
||||
}
|
||||
if owner.ID != "" {
|
||||
err = a.tenancy.CreateUserKey(ctx, owner.ID, k)
|
||||
} else {
|
||||
err = a.tenancy.CreateKey(ctx, k)
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNotMember):
|
||||
writeError(w, http.StatusConflict, fmt.Sprintf("%s is not a member of %s; add the membership first", owner.Email, path))
|
||||
return
|
||||
case errors.Is(err, store.ErrKeyTTL):
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
case err != nil:
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
k.UserID = owner.ID
|
||||
writeJSON(w, http.StatusCreated, KeyCreated{KeyView: keyView(k, path, owner.Email, time.Now()), Secret: secret})
|
||||
}
|
||||
|
||||
func (a *API) keyRevoke(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.tenancyReady(w) {
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if _, err := a.tenancy.KeyByID(r.Context(), id); err != nil {
|
||||
writeError(w, tenancyStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
if err := a.tenancy.RevokeKey(r.Context(), id, time.Now()); err != nil {
|
||||
writeError(w, tenancyStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
// A key held in a gateway's cache keeps working until auth.cache_ttl.
|
||||
writeJSON(w, http.StatusOK, map[string]any{"id": id, "revoked": true})
|
||||
}
|
||||
|
||||
func (a *API) userView(ctx context.Context, u store.User) UserView {
|
||||
role := u.Role
|
||||
if u.IsAdmin {
|
||||
role = "admin"
|
||||
}
|
||||
v := UserView{ID: u.ID, Email: u.Email, Name: u.Name, State: string(u.State), Role: role,
|
||||
Memberships: []MembershipView{}, CreatedAt: u.CreatedAt, LastLoginAt: u.LastLoginAt}
|
||||
if ms, err := a.tenancy.MembershipsOf(ctx, u.ID); err == nil {
|
||||
for _, m := range ms {
|
||||
v.Memberships = append(v.Memberships, MembershipView{Target: string(m.Scope) + ":" + m.ScopeID,
|
||||
Path: m.Path, Role: m.Role, At: m.AddedAt, By: m.AddedBy})
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (a *API) userList(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.tenancyReady(w) {
|
||||
return
|
||||
}
|
||||
us, err := a.tenancy.Users(r.Context(), store.UserState(r.URL.Query().Get("state")))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
out := []UserView{}
|
||||
for _, u := range us {
|
||||
out = append(out, a.userView(r.Context(), u))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (a *API) userGet(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.tenancyReady(w) {
|
||||
return
|
||||
}
|
||||
u, err := a.findUser(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, a.userView(r.Context(), u))
|
||||
}
|
||||
|
||||
func (a *API) membership(add bool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.tenancyReady(w) {
|
||||
return
|
||||
}
|
||||
var in MembershipRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
u, err := a.findUser(ctx, r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
scope, id, _, err := a.resolveScope(ctx, in.Target)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if add {
|
||||
role := in.Role
|
||||
if role == "" {
|
||||
role = store.RoleMember
|
||||
}
|
||||
if role != store.RoleMember && role != store.RoleManager {
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("role %q: member or manager", role))
|
||||
return
|
||||
}
|
||||
if err := a.tenancy.AddMembership(ctx, u.ID, scope, id, role, actorOf(r), time.Now()); err != nil {
|
||||
writeError(w, tenancyStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// The person's keys in projects they no longer reach are
|
||||
// revoked with it, by the store.
|
||||
if _, err := a.tenancy.RemoveMembership(ctx, u.ID, scope, id, time.Now()); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
err = errors.New(u.Email + " is not a member there")
|
||||
}
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, a.userView(ctx, u))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
func TestTheTenancyTreeKeysAndMembershipsAreAdministered(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
a, _ := setup(t, testModels())
|
||||
s, err := store.OpenSQLite(ctx, filepath.Join(t.TempDir(), "s.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
a.SetQuotas(s)
|
||||
a.SetTenancy(s)
|
||||
mux := http.NewServeMux()
|
||||
a.Register(mux)
|
||||
do := func(method, path, body string, out any) int {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest(method, path, strings.NewReader(body)))
|
||||
if out != nil && rec.Code < 300 {
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), out); err != nil {
|
||||
t.Fatalf("%s %s: %v (%s)", method, path, err, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if rec.Code >= 300 {
|
||||
t.Logf("%s %s: %d %s", method, path, rec.Code, strings.TrimSpace(rec.Body.String()))
|
||||
}
|
||||
return rec.Code
|
||||
}
|
||||
var made TenancyItem
|
||||
if c := do("POST", "/api/v1/orgs", `{"name":"Acme"}`, &made); c != http.StatusCreated {
|
||||
t.Fatalf("adding an organization: %d", c)
|
||||
}
|
||||
acme := made.ID
|
||||
if c := do("POST", "/api/v1/orgs", `{"name":"Acme"}`, nil); c != http.StatusConflict {
|
||||
t.Errorf("a second Acme: %d", c)
|
||||
}
|
||||
if c := do("POST", "/api/v1/teams", `{"name":"hr"}`, nil); c != http.StatusBadRequest {
|
||||
t.Errorf("a team in nothing: %d", c)
|
||||
}
|
||||
do("POST", "/api/v1/teams", `{"name":"hr","in":"Acme"}`, &made)
|
||||
hr := made.ID
|
||||
if c := do("POST", "/api/v1/projects", `{"name":"crm","in":"Acme/hr"}`, &made); c != http.StatusCreated || made.In != hr {
|
||||
t.Fatalf("adding a project: %d %v", c, made)
|
||||
}
|
||||
crm := made.ID
|
||||
do("POST", "/api/v1/orgs", `{"name":"Other"}`, &made)
|
||||
other := made.ID
|
||||
|
||||
// Renamed, and moved with what is in it.
|
||||
if c := do("PATCH", "/api/v1/projects/"+crm, `{"name":"crm2"}`, nil); c != http.StatusOK {
|
||||
t.Errorf("renaming: %d", c)
|
||||
}
|
||||
if c := do("PATCH", "/api/v1/teams/"+hr, `{"in":"Other"}`, nil); c != http.StatusOK {
|
||||
t.Errorf("moving: %d", c)
|
||||
}
|
||||
if c := do("PATCH", "/api/v1/orgs/"+acme, `{"in":"Other"}`, nil); c != http.StatusBadRequest {
|
||||
t.Errorf("moving an organization: %d", c)
|
||||
}
|
||||
var tree []OrgView
|
||||
do("GET", "/api/v1/orgs", "", &tree)
|
||||
if len(tree) != 2 || tree[1].ID != other || len(tree[1].Teams) != 1 ||
|
||||
tree[1].Teams[0].Projects[0].Path != "Other/hr/crm2" {
|
||||
t.Fatalf("the tree is %+v", tree)
|
||||
}
|
||||
|
||||
// A project's key: given once, listed without it.
|
||||
var key KeyCreated
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest("POST", "/api/v1/keys",
|
||||
strings.NewReader(`{"name":"svc","project":"Other/hr/crm2","expires_in":"24h"}`)))
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("issuing a key: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &key)
|
||||
if !strings.HasPrefix(key.Secret, "sk-llmgw-") || key.Project != crm || key.ExpiresAt.IsZero() {
|
||||
t.Errorf("the key issued: %+v", key)
|
||||
}
|
||||
if k, err := s.KeyByHash(ctx, store.HashKey(key.Secret)); err != nil || k.ID != key.ID {
|
||||
t.Errorf("the key does not authenticate: %v", err)
|
||||
}
|
||||
var keys []KeyView
|
||||
do("GET", "/api/v1/keys?project="+crm, "", &keys)
|
||||
if len(keys) != 1 || keys[0].ID != key.ID || keys[0].State != "active" || keys[0].ProjectPath != "Other/hr/crm2" {
|
||||
t.Errorf("the project's keys: %+v", keys)
|
||||
}
|
||||
raw := httptest.NewRecorder()
|
||||
mux.ServeHTTP(raw, httptest.NewRequest("GET", "/api/v1/keys", nil))
|
||||
if strings.Contains(raw.Body.String(), key.Secret) || strings.Contains(raw.Body.String(), "secret") {
|
||||
t.Error("a listing holds the key")
|
||||
}
|
||||
if c := do("POST", "/api/v1/keys", `{"name":"x","project":"Other/hr/crm2","expires_in":"soon"}`, nil); c != http.StatusBadRequest {
|
||||
t.Errorf("a lifetime that is not one: %d", c)
|
||||
}
|
||||
if c := do("DELETE", "/api/v1/projects/"+crm, "", nil); c != http.StatusConflict {
|
||||
t.Errorf("removing a project with a key: %d", c)
|
||||
}
|
||||
if c := do("DELETE", "/api/v1/keys/"+key.ID, "", nil); c != http.StatusOK {
|
||||
t.Errorf("revoking: %d", c)
|
||||
}
|
||||
var one KeyView
|
||||
do("GET", "/api/v1/keys/"+key.ID, "", &one)
|
||||
if one.State != "revoked" {
|
||||
t.Errorf("after revoking, %+v", one)
|
||||
}
|
||||
|
||||
// A person: a key in a project only as its member, revoked when they
|
||||
// leave it.
|
||||
if err := s.CreateUser(ctx, store.User{ID: "u1", Email: "ayse@example.com", PasswordHash: "x",
|
||||
State: store.UserActive, CreatedAt: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := do("POST", "/api/v1/keys", `{"name":"mine","project":"`+crm+`","user":"ayse@example.com"}`, nil); c != http.StatusConflict {
|
||||
t.Errorf("a key where its person is no member: %d", c)
|
||||
}
|
||||
var u UserView
|
||||
if c := do("POST", "/api/v1/users/ayse@example.com/memberships", `{"target":"project:Other/hr/crm2"}`, &u); c != http.StatusOK ||
|
||||
len(u.Memberships) != 1 || u.Memberships[0].Target != "project:"+crm || u.Memberships[0].Role != "member" {
|
||||
t.Fatalf("joining: %d %+v", c, u)
|
||||
}
|
||||
var mine KeyCreated
|
||||
rec = httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest("POST", "/api/v1/keys",
|
||||
strings.NewReader(`{"name":"mine","project":"`+crm+`","user":"u1"}`)))
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &mine)
|
||||
if rec.Code != http.StatusCreated || mine.User != "u1" {
|
||||
t.Fatalf("a member's key: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if c := do("DELETE", "/api/v1/users/u1/memberships", `{"target":"project:`+crm+`"}`, &u); c != http.StatusOK || len(u.Memberships) != 0 {
|
||||
t.Errorf("leaving: %d %+v", c, u)
|
||||
}
|
||||
do("GET", "/api/v1/keys/"+mine.ID, "", &one)
|
||||
if one.State != "revoked" {
|
||||
t.Errorf("the key of a person who left: %+v", one)
|
||||
}
|
||||
if c := do("DELETE", "/api/v1/users/u1/memberships", `{"target":"project:`+crm+`"}`, nil); c != http.StatusNotFound {
|
||||
t.Errorf("leaving twice: %d", c)
|
||||
}
|
||||
var people []UserView
|
||||
do("GET", "/api/v1/users", "", &people)
|
||||
if len(people) != 1 || people[0].Email != "ayse@example.com" {
|
||||
t.Errorf("people: %+v", people)
|
||||
}
|
||||
|
||||
// Only what is empty is removed.
|
||||
if c := do("DELETE", "/api/v1/orgs/Acme", "", nil); c != http.StatusOK {
|
||||
t.Errorf("removing the empty Acme by name: %d", c)
|
||||
}
|
||||
if c := do("DELETE", "/api/v1/orgs/"+other, "", nil); c != http.StatusConflict {
|
||||
t.Errorf("removing an organization with a team: %d", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenancyNeedsTheStore(t *testing.T) {
|
||||
a, _ := setup(t, testModels())
|
||||
if c := call(t, a, "GET", "/api/v1/orgs", "", nil); c != http.StatusNotImplemented {
|
||||
t.Errorf("without a store: %d", c)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/train"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
// Training LoRA adapters (BACKLOG F4), administered: a job names the model
|
||||
// the adapter is for, the weights to train on, the node and how much of a
|
||||
// card it needs, and the pairs; the node trains when it can, and what it
|
||||
// made is kept here to download or publish.
|
||||
|
||||
// TrainStore is where jobs are kept.
|
||||
type TrainStore interface {
|
||||
CreateTrainJob(ctx context.Context, j store.TrainJob) error
|
||||
TrainJobs(ctx context.Context, limit int) ([]store.TrainJob, error)
|
||||
TrainJob(ctx context.Context, id string) (store.TrainJob, error)
|
||||
TrainArtifact(ctx context.Context, job, kind string) ([]byte, error)
|
||||
TrainArtifactKinds(ctx context.Context, job string) ([]string, error)
|
||||
DeleteTrainJob(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
// TrainAdmin is what the training endpoints need.
|
||||
type TrainAdmin struct {
|
||||
Store TrainStore
|
||||
Service *train.Service
|
||||
// Mirror keeps what a job made where the nodes fetch weights; nil
|
||||
// leaves publishing off.
|
||||
Mirror TrainMirror
|
||||
}
|
||||
|
||||
// TrainMirror is the model mirror, as publishing needs it.
|
||||
type TrainMirror interface {
|
||||
Upload(ctx context.Context, model, version, file string, body []byte, source string) (mirror.FetchResponse, error)
|
||||
}
|
||||
|
||||
// TrainPublishView is an adapter published: where the nodes fetch it from,
|
||||
// and what to do for replicas to load it.
|
||||
type TrainPublishView struct {
|
||||
Adapter string `json:"adapter"`
|
||||
Model string `json:"model"`
|
||||
Source string `json:"source"`
|
||||
// Owner is who sees it; empty is every caller of the model.
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// SetTrain enables the training endpoints.
|
||||
func (a *API) SetTrain(t *TrainAdmin) { a.train = t }
|
||||
|
||||
// SetTrainMirror gives publishing the mirror, which is set up after the
|
||||
// training endpoints are.
|
||||
func (a *API) SetTrainMirror(m TrainMirror) {
|
||||
if a.train != nil {
|
||||
a.train.Mirror = m
|
||||
}
|
||||
}
|
||||
|
||||
// TrainRequest queues a job.
|
||||
type TrainRequest struct {
|
||||
// Name is the adapter's: what requests will ask for it by.
|
||||
Name string `json:"name"`
|
||||
// Model is the catalog model the adapter is for.
|
||||
Model string `json:"model"`
|
||||
// BaseRepo and BaseRevision are the weights trained on: the model's own
|
||||
// on its registry, in safetensors (Hugging Face), not a GGUF.
|
||||
BaseRepo string `json:"base_repo"`
|
||||
BaseRevision string `json:"base_revision,omitempty"`
|
||||
// Node trains it; GPUMemoryMiB is what it needs of a card, which can be
|
||||
// what an engine there leaves free.
|
||||
Node string `json:"node"`
|
||||
GPUMemoryMiB int `json:"gpu_memory_mib"`
|
||||
// Owner is who the adapter will belong to: org:, team:, project: (by
|
||||
// path or ID), user: or key:. Only callers there see it. When the
|
||||
// pairs come from one knowledge base, it defaults to that collection's
|
||||
// owner; it must be able to read every collection they come from.
|
||||
Owner string `json:"owner,omitempty"`
|
||||
// Public makes the adapter every caller's its model allows. Refused for
|
||||
// pairs from a knowledge base.
|
||||
Public bool `json:"public,omitempty"`
|
||||
// Data is the pairs, NDJSON of {"q", "a"} (llmgw ctl kb dataset).
|
||||
Data string `json:"data"`
|
||||
// Params tune the trainer: rank, alpha, dropout, epochs, lr, batch,
|
||||
// max_len, seed.
|
||||
Params map[string]any `json:"params,omitempty"`
|
||||
// GGUF also makes the adapter in llama.cpp's format; when absent, it
|
||||
// does when the model is served by llama.cpp.
|
||||
GGUF *bool `json:"gguf,omitempty"`
|
||||
}
|
||||
|
||||
// TrainJobView is a job as listed.
|
||||
type TrainJobView struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Model string `json:"model"`
|
||||
BaseRepo string `json:"base_repo"`
|
||||
BaseRevision string `json:"base_revision,omitempty"`
|
||||
Node string `json:"node"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
GPUMemoryMiB int `json:"gpu_memory_mib"`
|
||||
Params string `json:"params,omitempty"`
|
||||
GGUF bool `json:"gguf"`
|
||||
Pairs int `json:"pairs"`
|
||||
State string `json:"state"`
|
||||
Step int `json:"step"`
|
||||
Steps int `json:"steps"`
|
||||
Loss float64 `json:"loss"`
|
||||
Message string `json:"message,omitempty"`
|
||||
PeakMiB int `json:"peak_mib"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
StartedAt time.Time `json:"started_at,omitzero"`
|
||||
FinishedAt time.Time `json:"finished_at,omitzero"`
|
||||
CreatedBy string `json:"created_by,omitempty"`
|
||||
// Artifacts are what it made: peft (a gzipped tar), gguf.
|
||||
Artifacts []string `json:"artifacts,omitempty"`
|
||||
}
|
||||
|
||||
func trainView(j store.TrainJob) TrainJobView {
|
||||
return TrainJobView{ID: j.ID, Name: j.Name, Model: j.Model, BaseRepo: j.BaseRepo, BaseRevision: j.BaseRevision,
|
||||
Node: j.Node, Owner: j.Owner, GPUMemoryMiB: j.GPUMemoryMiB, Params: j.Params, GGUF: j.GGUF, Pairs: j.Pairs, State: j.State,
|
||||
Step: j.Step, Steps: j.Steps, Loss: j.Loss, Message: j.Message, PeakMiB: j.PeakMiB, CreatedAt: j.CreatedAt,
|
||||
StartedAt: j.StartedAt, FinishedAt: j.FinishedAt, CreatedBy: j.CreatedBy}
|
||||
}
|
||||
|
||||
func (a *API) registerTrain(mux Mux) {
|
||||
mux.HandleFunc("POST /api/v1/train/jobs", a.trainCreate)
|
||||
mux.HandleFunc("GET /api/v1/train/jobs", a.trainList)
|
||||
mux.HandleFunc("GET /api/v1/train/jobs/{id}", a.trainGet)
|
||||
mux.HandleFunc("POST /api/v1/train/jobs/{id}/cancel", a.trainCancel)
|
||||
mux.HandleFunc("DELETE /api/v1/train/jobs/{id}", a.trainDelete)
|
||||
mux.HandleFunc("GET /api/v1/train/jobs/{id}/artifacts/{kind}", a.trainArtifact)
|
||||
mux.HandleFunc("POST /api/v1/train/jobs/{id}/publish", a.trainPublish)
|
||||
}
|
||||
|
||||
func (a *API) trainReady(w http.ResponseWriter) bool {
|
||||
if a.train == nil {
|
||||
writeError(w, http.StatusNotImplemented, "training needs the store and the nodes' agents, which this control plane runs without")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// trainMaxData bounds a job's pairs.
|
||||
const trainMaxData = 64 << 20
|
||||
|
||||
var adapterName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$`)
|
||||
|
||||
// trainParams are the trainer's settings, with what each may be.
|
||||
var trainParams = map[string][2]float64{
|
||||
"rank": {1, 256}, "alpha": {1, 1024}, "dropout": {0, 0.9}, "epochs": {1, 100},
|
||||
"lr": {1e-7, 1e-2}, "batch": {1, 128}, "max_len": {16, 32768}, "seed": {0, 1 << 31},
|
||||
}
|
||||
|
||||
func (a *API) trainCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.trainReady(w) {
|
||||
return
|
||||
}
|
||||
var in TrainRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, trainMaxData+1<<20)).Decode(&in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
bad := func(format string, args ...any) {
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf(format, args...))
|
||||
}
|
||||
if !adapterName.MatchString(in.Name) {
|
||||
bad("an adapter's name is letters, digits, . _ and -, up to 100: %q is not", in.Name)
|
||||
return
|
||||
}
|
||||
m, ok := a.ctl.Catalog().Resolve(in.Model)
|
||||
if !ok {
|
||||
bad("no model %s in the catalog", in.Model)
|
||||
return
|
||||
}
|
||||
if _, _, _, taken := a.ctl.Catalog().ResolveAdapter(in.Name); taken || in.Name == m.Name {
|
||||
bad("%s is already a model's or an adapter's name", in.Name)
|
||||
return
|
||||
}
|
||||
if strings.Count(in.BaseRepo, "/") != 1 || strings.ContainsAny(in.BaseRepo, " :") {
|
||||
bad("base_repo is the weights' registry repository, owner/name: %q is not", in.BaseRepo)
|
||||
return
|
||||
}
|
||||
if _, ok := a.ctl.Inventory().Get(in.Node); !ok {
|
||||
bad("no node %s", in.Node)
|
||||
return
|
||||
}
|
||||
if in.GPUMemoryMiB <= 0 {
|
||||
bad("gpu_memory_mib is what the job needs of a card, in MiB")
|
||||
return
|
||||
}
|
||||
pairs, colls, err := countPairs(in.Data)
|
||||
if err != nil {
|
||||
bad("%v", err)
|
||||
return
|
||||
}
|
||||
owner, err := a.trainOwner(r.Context(), in, colls)
|
||||
if err != nil {
|
||||
bad("%v", err)
|
||||
return
|
||||
}
|
||||
for k, v := range in.Params {
|
||||
lim, known := trainParams[k]
|
||||
f, isNum := v.(float64)
|
||||
if !known || !isNum || f < lim[0] || f > lim[1] {
|
||||
bad("params.%s: the trainer takes rank, alpha, dropout, epochs, lr, batch, max_len and seed, each a number in its range", k)
|
||||
return
|
||||
}
|
||||
}
|
||||
params := ""
|
||||
if len(in.Params) > 0 {
|
||||
b, _ := json.Marshal(in.Params)
|
||||
params = string(b)
|
||||
}
|
||||
gguf := false
|
||||
for _, p := range m.Profiles {
|
||||
if p.Engine == "llamacpp" {
|
||||
gguf = true
|
||||
}
|
||||
}
|
||||
if in.GGUF != nil {
|
||||
gguf = *in.GGUF
|
||||
}
|
||||
j := store.TrainJob{ID: store.NewID("train-"), Name: in.Name, Model: m.Name, BaseRepo: in.BaseRepo,
|
||||
BaseRevision: in.BaseRevision, Node: in.Node, Owner: owner, GPUMemoryMiB: in.GPUMemoryMiB, Params: params, GGUF: gguf,
|
||||
Data: []byte(in.Data), Pairs: pairs, CreatedBy: actorOf(r)}
|
||||
if err := a.train.Store.CreateTrainJob(r.Context(), j); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
// At once rather than at the next look, when the node is here.
|
||||
go a.train.Service.Dispatch(context.WithoutCancel(r.Context()))
|
||||
j.State = store.TrainQueued
|
||||
writeJSON(w, http.StatusCreated, trainView(j))
|
||||
}
|
||||
|
||||
// trainOwner is who a job's adapter will belong to, as scope:ID: one that
|
||||
// may read every knowledge base the pairs come from. Pairs from a knowledge
|
||||
// base never make an adapter everyone's.
|
||||
func (a *API) trainOwner(ctx context.Context, in TrainRequest, colls []string) (string, error) {
|
||||
if in.Public {
|
||||
if len(colls) > 0 {
|
||||
return "", errors.New("these pairs come from a knowledge base, and an adapter trained on them " +
|
||||
"carries some of what it says: it belongs to a part of the tree that may read it, not to everyone")
|
||||
}
|
||||
if in.Owner != "" {
|
||||
return "", errors.New("an adapter is public or has an owner, not both")
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
if len(colls) > 0 && a.kb == nil {
|
||||
return "", errors.New("the pairs name knowledge bases, which this control plane cannot read")
|
||||
}
|
||||
var found []store.KBCollection
|
||||
for _, id := range colls {
|
||||
c, err := a.kb.Store.Collection(ctx, id)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the pairs come from collection %s, which is not here: %w", id, err)
|
||||
}
|
||||
found = append(found, c)
|
||||
}
|
||||
if in.Owner == "" {
|
||||
if len(found) == 1 {
|
||||
return string(found[0].OwnerScope) + ":" + found[0].OwnerID, nil
|
||||
}
|
||||
return "", errors.New("name the adapter's owner (org:, team:, project:, user: or key:), or make it public " +
|
||||
"when its pairs are not a knowledge base's")
|
||||
}
|
||||
scope, id, err := a.kbTarget(ctx, in.Owner)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, c := range found {
|
||||
e, err := a.kb.Store.EffectiveFor(ctx, scope, id, c.OrgID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: %w", in.Owner, err)
|
||||
}
|
||||
readable, err := a.kb.Store.CollectionsFor(ctx, e, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !slices.ContainsFunc(readable, func(x store.KBCollection) bool { return x.ID == c.ID }) {
|
||||
return "", fmt.Errorf("%s may not read collection %s, which the pairs come from: "+
|
||||
"an adapter trained on it would tell its callers what it says", in.Owner, c.Name)
|
||||
}
|
||||
}
|
||||
return string(scope) + ":" + id, nil
|
||||
}
|
||||
|
||||
// countPairs checks a job's data: NDJSON of {"q", "a"}, at least one, and
|
||||
// says which knowledge bases the pairs come from.
|
||||
func countPairs(data string) (int, []string, error) {
|
||||
if len(data) > trainMaxData {
|
||||
return 0, nil, fmt.Errorf("the data is over %d MiB", trainMaxData>>20)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var colls []string
|
||||
n := 0
|
||||
sc := bufio.NewScanner(strings.NewReader(data))
|
||||
sc.Buffer(make([]byte, 64<<10), 1<<20)
|
||||
line := 0
|
||||
for sc.Scan() {
|
||||
line++
|
||||
b := bytes.TrimSpace(sc.Bytes())
|
||||
if len(b) == 0 {
|
||||
continue
|
||||
}
|
||||
var p struct{ Q, A, Collection string }
|
||||
if err := json.Unmarshal(b, &p); err != nil {
|
||||
return 0, nil, fmt.Errorf("data line %d is not JSON: %v", line, err)
|
||||
}
|
||||
if strings.TrimSpace(p.Q) == "" || strings.TrimSpace(p.A) == "" {
|
||||
return 0, nil, fmt.Errorf("data line %d has no q or no a", line)
|
||||
}
|
||||
if p.Collection != "" && !seen[p.Collection] {
|
||||
seen[p.Collection] = true
|
||||
colls = append(colls, p.Collection)
|
||||
}
|
||||
n++
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if n == 0 {
|
||||
return 0, nil, errors.New("the data holds no pairs: NDJSON of {\"q\", \"a\"}, as llmgw ctl kb dataset writes")
|
||||
}
|
||||
return n, colls, nil
|
||||
}
|
||||
|
||||
func (a *API) trainList(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.trainReady(w) {
|
||||
return
|
||||
}
|
||||
limit := 50
|
||||
if n, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && n > 0 && n <= 500 {
|
||||
limit = n
|
||||
}
|
||||
jobs, err := a.train.Store.TrainJobs(r.Context(), limit)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
out := []TrainJobView{}
|
||||
for _, j := range jobs {
|
||||
out = append(out, trainView(j))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (a *API) trainJob(w http.ResponseWriter, r *http.Request) (store.TrainJob, bool) {
|
||||
if !a.trainReady(w) {
|
||||
return store.TrainJob{}, false
|
||||
}
|
||||
j, err := a.train.Store.TrainJob(r.Context(), r.PathValue("id"))
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "no training job "+r.PathValue("id"))
|
||||
return j, false
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return j, false
|
||||
}
|
||||
return j, true
|
||||
}
|
||||
|
||||
func (a *API) trainGet(w http.ResponseWriter, r *http.Request) {
|
||||
j, ok := a.trainJob(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
v := trainView(j)
|
||||
v.Artifacts, _ = a.train.Store.TrainArtifactKinds(r.Context(), j.ID)
|
||||
writeJSON(w, http.StatusOK, v)
|
||||
}
|
||||
|
||||
func (a *API) trainCancel(w http.ResponseWriter, r *http.Request) {
|
||||
j, ok := a.trainJob(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := a.train.Service.Cancel(r.Context(), j.ID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
j, _ = a.train.Store.TrainJob(r.Context(), j.ID)
|
||||
writeJSON(w, http.StatusOK, trainView(j))
|
||||
}
|
||||
|
||||
func (a *API) trainDelete(w http.ResponseWriter, r *http.Request) {
|
||||
j, ok := a.trainJob(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !store.TrainOver(j.State) {
|
||||
writeError(w, http.StatusConflict, "the job has not ended: cancel it first")
|
||||
return
|
||||
}
|
||||
if err := a.train.Store.DeleteTrainJob(r.Context(), j.ID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"id": j.ID, "deleted": true})
|
||||
}
|
||||
|
||||
func (a *API) trainArtifact(w http.ResponseWriter, r *http.Request) {
|
||||
j, ok := a.trainJob(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
kind := r.PathValue("kind")
|
||||
b, err := a.train.Store.TrainArtifact(r.Context(), j.ID, kind)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "the job made no "+kind)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
name := j.Name + ".tar.gz"
|
||||
w.Header().Set("Content-Type", "application/gzip")
|
||||
if kind == "gguf" {
|
||||
name = j.Name + ".gguf"
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
}
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(b)))
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
|
||||
// trainPublish puts what a job made on the mirror and makes it the model's
|
||||
// adapter: the GGUF for a model llama.cpp serves, the PEFT directory for
|
||||
// one vLLM serves.
|
||||
func (a *API) trainPublish(w http.ResponseWriter, r *http.Request) {
|
||||
j, ok := a.trainJob(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if a.train.Mirror == nil {
|
||||
writeError(w, http.StatusNotImplemented, "publishing needs the model mirror (mirror.api in the settings); "+
|
||||
"llmgw ctl train download, then llmgw mirror import there, does it by hand")
|
||||
return
|
||||
}
|
||||
if a.models == nil {
|
||||
writeError(w, http.StatusNotImplemented, "this control plane has no store for the catalog")
|
||||
return
|
||||
}
|
||||
if j.State != store.TrainDone {
|
||||
writeError(w, http.StatusConflict, "the job has not finished: it is "+j.State)
|
||||
return
|
||||
}
|
||||
m, found := a.ctl.Catalog().Resolve(j.Model)
|
||||
if !found {
|
||||
writeError(w, http.StatusConflict, "the model "+j.Model+" is no longer in the catalog")
|
||||
return
|
||||
}
|
||||
for _, ad := range m.Adapters {
|
||||
if ad.Name == j.Name {
|
||||
writeError(w, http.StatusConflict, j.Name+" is already "+m.Name+"'s adapter; train under another name")
|
||||
return
|
||||
}
|
||||
}
|
||||
llama := slices.ContainsFunc(m.Profiles, func(p catalog.Profile) bool { return p.Engine == "llamacpp" })
|
||||
source := "trained by llmgw job " + j.ID + " on " + j.Node + " from " + j.BaseRepo
|
||||
var url string
|
||||
if llama {
|
||||
b, err := a.train.Store.TrainArtifact(r.Context(), j.ID, "gguf")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusConflict, "the job made no GGUF, which llama.cpp needs; train again without --no-gguf")
|
||||
return
|
||||
}
|
||||
res, err := a.train.Mirror.Upload(r.Context(), j.Name, j.ID, j.Name+".gguf", b, source)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "keeping it on the mirror: "+err.Error())
|
||||
return
|
||||
}
|
||||
url = res.URL
|
||||
} else {
|
||||
b, err := a.train.Store.TrainArtifact(r.Context(), j.ID, "peft")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusConflict, "the job made no PEFT adapter")
|
||||
return
|
||||
}
|
||||
files, err := untarAll(b)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
for name, data := range files {
|
||||
res, err := a.train.Mirror.Upload(r.Context(), j.Name, j.ID, name, data, source)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "keeping "+name+" on the mirror: "+err.Error())
|
||||
return
|
||||
}
|
||||
url = res.URL[:strings.LastIndex(res.URL, "/")+1]
|
||||
}
|
||||
}
|
||||
ad := catalog.Adapter{Name: j.Name, Source: url, Owner: j.Owner}
|
||||
if err := checkAdapters(r.Context(), m, []catalog.Adapter{ad}); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, err := a.models.SetAdapters(r.Context(), m.Name, append(slices.Clone(m.Adapters), ad), actorOf(r)); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
v := TrainPublishView{Adapter: j.Name, Model: m.Name, Source: url, Owner: j.Owner}
|
||||
if llama {
|
||||
v.Note = "llama.cpp loads adapters when a replica starts: restart " + m.Name + "'s replicas (llmgw ctl restart " +
|
||||
m.Name + ") and ask for model " + j.Name
|
||||
} else {
|
||||
v.Note = "ask for model " + j.Name + "; a replica loads it on the first request"
|
||||
}
|
||||
writeJSON(w, http.StatusOK, v)
|
||||
}
|
||||
|
||||
// untarAll reads a gzipped tar of a PEFT directory's files.
|
||||
func untarAll(b []byte) (map[string][]byte, error) {
|
||||
gz, err := gzip.NewReader(bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tr := tar.NewReader(gz)
|
||||
out := map[string][]byte{}
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return out, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if h.Typeflag != tar.TypeReg || strings.ContainsAny(h.Name, "/\\") {
|
||||
continue
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(tr, 4<<30))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[h.Name] = data
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/train"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
type noAgents struct{}
|
||||
|
||||
func (noAgents) Send(string, *llmgwv1.ControlMessage) error { return nil }
|
||||
func (noAgents) Connected(string) bool { return false }
|
||||
|
||||
func TestATrainingJobIsCheckedBeforeItIsQueued(t *testing.T) {
|
||||
models := append(testModels(), catalog.Model{Name: "small", Source: "/s",
|
||||
Profiles: []catalog.Profile{{Name: "cpu", Engine: "llamacpp"}}})
|
||||
a, _ := setup(t, models, gpuNode("cuda01", 1))
|
||||
s, err := store.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "s.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
a.SetTrain(&TrainAdmin{Store: s, Service: train.New(s, noAgents{}, nil)})
|
||||
mux := http.NewServeMux()
|
||||
a.Register(mux)
|
||||
post := func(body string) (int, string) {
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest("POST", "/api/v1/train/jobs", strings.NewReader(body)))
|
||||
return rec.Code, rec.Body.String()
|
||||
}
|
||||
good := map[string]any{"name": "tone", "model": "small", "base_repo": "Qwen/Qwen2.5-0.5B-Instruct", "node": "cuda01",
|
||||
"gpu_memory_mib": 2300, "data": "{\"q\":\"a\",\"a\":\"b\"}\n{\"q\":\"c\",\"a\":\"d\"}\n", "params": map[string]any{"epochs": 3},
|
||||
"public": true}
|
||||
with := func(k string, v any) string {
|
||||
m := map[string]any{}
|
||||
for kk, vv := range good {
|
||||
m[kk] = vv
|
||||
}
|
||||
m[k] = v
|
||||
b, _ := json.Marshal(m)
|
||||
return string(b)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
field string
|
||||
value any
|
||||
says string
|
||||
}{
|
||||
{"name", "bad name", "letters, digits"},
|
||||
{"model", "nope", "no model nope"},
|
||||
{"name", "small", "already a model"},
|
||||
{"base_repo", "Qwen", "owner/name"},
|
||||
{"node", "gone", "no node gone"},
|
||||
{"data", "{\"q\":\"a\"}\n", "no q or no a"},
|
||||
{"data", "", "no pairs"},
|
||||
{"params", map[string]any{"epochs": 0}, "params.epochs"},
|
||||
{"params", map[string]any{"momentum": 0.9}, "params.momentum"},
|
||||
{"public", false, "name the adapter's owner"},
|
||||
{"data", "{\"q\":\"a\",\"a\":\"b\",\"collection\":\"kb_aaaaaaaaaaaaaaaa\"}\n", "from a knowledge base"},
|
||||
} {
|
||||
if code, body := post(with(c.field, c.value)); code != http.StatusBadRequest || !strings.Contains(body, c.says) {
|
||||
t.Errorf("%s=%v: %d %s", c.field, c.value, code, body)
|
||||
}
|
||||
}
|
||||
code, body := post(with("name", "tone"))
|
||||
var v TrainJobView
|
||||
_ = json.Unmarshal([]byte(body), &v)
|
||||
if code != http.StatusCreated || v.Pairs != 2 || !v.GGUF || v.State != "queued" || v.Params != `{"epochs":3}` {
|
||||
t.Fatalf("%d %s", code, body)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest("DELETE", "/api/v1/train/jobs/"+v.ID, nil))
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Errorf("deleting a queued job: %d", rec.Code)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest("GET", "/api/v1/train/jobs/"+v.ID+"/artifacts/peft", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("an artifact not made: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAdapterTrainedOnAKnowledgeBaseBelongsToWhoMayReadIt(t *testing.T) {
|
||||
dsn := os.Getenv("LLMGW_TEST_POSTGRES")
|
||||
if dsn == "" {
|
||||
t.Skip("set LLMGW_TEST_POSTGRES to a pgvector database to run this")
|
||||
}
|
||||
ctx := context.Background()
|
||||
s, err := store.OpenPostgres(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
if s.KnowledgeAvailable() != nil {
|
||||
t.Skip("no pgvector")
|
||||
}
|
||||
models := append(testModels(), catalog.Model{Name: "small", Source: "/s",
|
||||
Profiles: []catalog.Profile{{Name: "cpu", Engine: "llamacpp"}}})
|
||||
a, _ := setup(t, models, gpuNode("cuda01", 1))
|
||||
a.SetQuotas(s)
|
||||
a.SetKB(&KBAdmin{Store: s})
|
||||
a.SetTrain(&TrainAdmin{Store: s, Service: train.New(s, noAgents{}, nil)})
|
||||
sfx := strings.ToLower(store.NewID(""))
|
||||
org, _ := s.CreateOrg(ctx, "Tr"+sfx)
|
||||
hr, _ := s.CreateTeam(ctx, org, "hr")
|
||||
_, _ = s.CreateProject(ctx, hr, "docs")
|
||||
sales, _ := s.CreateTeam(ctx, org, "sales")
|
||||
_, _ = s.CreateProject(ctx, sales, "crm")
|
||||
c, err := s.CreateCollection(ctx, store.KBCollection{OwnerScope: store.ScopeTeam, OwnerID: hr, Name: "p", EmbedModel: "bge-m3", Dims: 3})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
a.Register(mux)
|
||||
post := func(extra string) (int, TrainJobView, string) {
|
||||
body := `{"name":"tone-` + store.NewID("") + `","model":"small","base_repo":"Qwen/Q","node":"cuda01","gpu_memory_mib":2000,` +
|
||||
`"data":"{\"q\":\"a\",\"a\":\"b\",\"collection\":\"` + c.ID + `\"}\n"` + extra + `}`
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest("POST", "/api/v1/train/jobs", strings.NewReader(body)))
|
||||
var v TrainJobView
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &v)
|
||||
return rec.Code, v, rec.Body.String()
|
||||
}
|
||||
if code, v, body := post(""); code != http.StatusCreated || v.Owner != "team:"+hr {
|
||||
t.Errorf("by default: %d %s", code, body)
|
||||
}
|
||||
if code, v, body := post(`,"owner":"project:Tr` + sfx + `/hr/docs"`); code != http.StatusCreated || !strings.HasPrefix(v.Owner, "project:") {
|
||||
t.Errorf("a project of the team: %d %s", code, body)
|
||||
}
|
||||
if code, _, body := post(`,"owner":"project:Tr` + sfx + `/sales/crm"`); code != http.StatusBadRequest || !strings.Contains(body, "may not read") {
|
||||
t.Errorf("a project that cannot read it: %d %s", code, body)
|
||||
}
|
||||
if code, _, body := post(`,"public":true`); code != http.StatusBadRequest || !strings.Contains(body, "not to everyone") {
|
||||
t.Errorf("public: %d %s", code, body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package agentsvc
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
|
||||
)
|
||||
|
||||
// SetAgentSettings gives the service each node's agent settings from the
|
||||
// cluster's (BACKLOG E3), as YAML, read at each use so a change is what is
|
||||
// sent next.
|
||||
func (s *Service) SetAgentSettings(f func(node string) string) {
|
||||
s.mu.Lock()
|
||||
s.agentSettings = f
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Service) agentSettingsMsg(node string) *llmgwv1.ControlMessage {
|
||||
s.mu.RLock()
|
||||
f := s.agentSettings
|
||||
s.mu.RUnlock()
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
return &llmgwv1.ControlMessage{Msg: &llmgwv1.ControlMessage_AgentSettings{
|
||||
AgentSettings: &llmgwv1.AgentSettings{Yaml: f(node)}}}
|
||||
}
|
||||
|
||||
// agentSettingsGap is the pause between telling one node and the next: an
|
||||
// agent whose settings changed starts again, and a whole cluster's agents
|
||||
// doing so in the same second would all re-register at once.
|
||||
var agentSettingsGap = time.Second
|
||||
|
||||
// BroadcastAgentSettings tells every connected agent what the cluster's
|
||||
// settings now say it runs on, a moment apart. A node not connected is told
|
||||
// when it registers.
|
||||
func (s *Service) BroadcastAgentSettings() {
|
||||
for _, node := range s.ConnectedNodes() {
|
||||
msg := s.agentSettingsMsg(node)
|
||||
if msg == nil {
|
||||
return
|
||||
}
|
||||
if err := s.send(node, msg); err != nil {
|
||||
s.log.Warn("could not tell a node its agent settings; it is told when it next registers",
|
||||
"node", node, "err", err)
|
||||
continue
|
||||
}
|
||||
time.Sleep(agentSettingsGap)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package agentsvc
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory"
|
||||
)
|
||||
|
||||
func TestEachNodeIsToldItsOwnAgentSettings(t *testing.T) {
|
||||
s := New(inventory.NewStore(), nil, false, slog.New(slog.DiscardHandler))
|
||||
if s.agentSettingsMsg("n1") != nil {
|
||||
t.Error("told settings with nothing to read them from")
|
||||
}
|
||||
s.SetAgentSettings(func(node string) string {
|
||||
if node == "cuda01" {
|
||||
return "labels:\n gpu: rtx6000\n"
|
||||
}
|
||||
return ""
|
||||
})
|
||||
if got := s.agentSettingsMsg("cuda01").GetAgentSettings().GetYaml(); got != "labels:\n gpu: rtx6000\n" {
|
||||
t.Errorf("cuda01 told %q", got)
|
||||
}
|
||||
// None is said too, so an agent whose settings were removed drops them.
|
||||
if m := s.agentSettingsMsg("node01"); m == nil || m.GetAgentSettings().GetYaml() != "" {
|
||||
t.Errorf("node01 told %v", m)
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,11 @@ type Service struct {
|
||||
controlPlanes func(context.Context) ([]string, error)
|
||||
// firewall is what nodes are told of llmgw's firewall; see SetFirewall.
|
||||
firewall func() *llmgwv1.Firewall
|
||||
// agentSettings is what each node's agent is told to run on; see
|
||||
// SetAgentSettings.
|
||||
agentSettings func(node string) string
|
||||
// train is told how training jobs go; see SetTrain.
|
||||
train TrainHandler
|
||||
|
||||
// logWaits are the log requests awaiting a reply, by request id.
|
||||
logMu sync.Mutex
|
||||
@@ -296,6 +301,13 @@ func (s *Service) Connect(
|
||||
return err
|
||||
}
|
||||
}
|
||||
// What the cluster's settings say this agent runs on. An agent already
|
||||
// on them ignores it; one that is not starts again on them.
|
||||
if msg := s.agentSettingsMsg(nodeID); msg != nil {
|
||||
if err := stream.Send(msg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Recover replica state from what this node is already running, before any
|
||||
// reconciliation pass can conclude the cluster is empty and place
|
||||
@@ -358,6 +370,9 @@ func (s *Service) readLoop(ctx context.Context,
|
||||
msg, err := stream.Receive()
|
||||
if err != nil {
|
||||
c.close()
|
||||
if h := s.trainHandler(); h != nil {
|
||||
h.OnNodeGone(nodeID)
|
||||
}
|
||||
if ctx.Err() != nil || errors.Is(err, io.EOF) {
|
||||
s.log.Info("agent disconnected", "node", nodeID)
|
||||
return
|
||||
@@ -495,6 +510,16 @@ func (s *Service) handle(nodeID string, msg *llmgwv1.AgentMessage) {
|
||||
ch <- m.Upgrade
|
||||
}
|
||||
|
||||
case *llmgwv1.AgentMessage_TrainStatus:
|
||||
if h := s.trainHandler(); h != nil {
|
||||
h.OnTrainStatus(nodeID, m.TrainStatus)
|
||||
}
|
||||
|
||||
case *llmgwv1.AgentMessage_TrainArtifact:
|
||||
if h := s.trainHandler(); h != nil {
|
||||
h.OnTrainArtifact(nodeID, m.TrainArtifact)
|
||||
}
|
||||
|
||||
case *llmgwv1.AgentMessage_Logs:
|
||||
s.logMu.Lock()
|
||||
ch, ok := s.logWaits[m.Logs.GetRequestId()]
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package agentsvc
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
|
||||
)
|
||||
|
||||
// TrainHandler is told how training jobs on the nodes go (BACKLOG F4).
|
||||
type TrainHandler interface {
|
||||
OnTrainStatus(node string, st *llmgwv1.TrainStatus)
|
||||
OnTrainArtifact(node string, a *llmgwv1.TrainArtifact)
|
||||
// OnNodeGone is a node's stream ending: a job running there ended with
|
||||
// it.
|
||||
OnNodeGone(node string)
|
||||
}
|
||||
|
||||
// SetTrain gives the service what training jobs' messages go to.
|
||||
func (s *Service) SetTrain(h TrainHandler) {
|
||||
s.mu.Lock()
|
||||
s.train = h
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Service) trainHandler() TrainHandler {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.train
|
||||
}
|
||||
|
||||
// Send gives a connected node's agent a command.
|
||||
func (s *Service) Send(node string, msg *llmgwv1.ControlMessage) error { return s.send(node, msg) }
|
||||
|
||||
// Connected is whether a node's agent holds a stream to this control plane.
|
||||
func (s *Service) Connected(node string) bool { return slices.Contains(s.ConnectedNodes(), node) }
|
||||
@@ -1127,6 +1127,27 @@ type Adapter struct {
|
||||
Source string `json:"source" koanf:"source"`
|
||||
// Scale is how strongly it is applied; zero takes 1.
|
||||
Scale float64 `json:"scale,omitempty" koanf:"scale"`
|
||||
// Owner is the part of the tenancy tree the adapter belongs to —
|
||||
// org:ID, team:ID, project:ID, user:ID or key:ID — and only callers in
|
||||
// it see it: to anyone else it is a model that does not exist. An
|
||||
// adapter trained on an organisation's documents carries some of what
|
||||
// they say in its weights. Empty is every caller the model allows.
|
||||
Owner string `json:"owner,omitempty" koanf:"owner"`
|
||||
}
|
||||
|
||||
// ValidAdapterOwner checks an adapter's owner is scope:ID.
|
||||
func ValidAdapterOwner(owner string) error {
|
||||
if owner == "" {
|
||||
return nil
|
||||
}
|
||||
scope, id, ok := strings.Cut(owner, ":")
|
||||
switch {
|
||||
case !ok || id == "":
|
||||
return fmt.Errorf("an adapter's owner is scope:ID, %q is not", owner)
|
||||
case scope != "org" && scope != "team" && scope != "project" && scope != "user" && scope != "key":
|
||||
return fmt.Errorf("an adapter's owner is an org, team, project, user or key, not %q", scope)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EffectiveScale is the scale an adapter is applied at.
|
||||
@@ -1175,6 +1196,9 @@ func validateAdapters(m Model) error {
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, a := range m.Adapters {
|
||||
if err := ValidAdapterOwner(a.Owner); err != nil {
|
||||
return fmt.Errorf("model %q adapter %q: %w", m.Name, a.Name, err)
|
||||
}
|
||||
switch {
|
||||
case a.Name == "" || a.Source == "":
|
||||
return fmt.Errorf("model %q: an adapter needs a name and a source", m.Name)
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
// Package train runs LoRA training jobs from the control plane (BACKLOG F4):
|
||||
// queued in the store, sent to the node each names when its agent is there
|
||||
// and has nothing else training, followed as the agent reports, and what it
|
||||
// makes kept in the store.
|
||||
package train
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
// Store is where jobs are kept.
|
||||
type Store interface {
|
||||
QueuedTrainJobs(ctx context.Context) ([]store.TrainJob, error)
|
||||
ActiveTrainJobs(ctx context.Context) ([]store.TrainJob, error)
|
||||
TrainJob(ctx context.Context, id string) (store.TrainJob, error)
|
||||
UpdateTrainJob(ctx context.Context, j store.TrainJob) error
|
||||
PutTrainArtifact(ctx context.Context, job, kind string, data []byte) error
|
||||
}
|
||||
|
||||
// Agents is how jobs reach the nodes.
|
||||
type Agents interface {
|
||||
Send(node string, msg *llmgwv1.ControlMessage) error
|
||||
Connected(node string) bool
|
||||
}
|
||||
|
||||
// Service sends queued jobs and records how they go.
|
||||
type Service struct {
|
||||
store Store
|
||||
agents Agents
|
||||
log *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
// pieces are artifacts arriving, by job and kind.
|
||||
pieces map[[2]string][]byte
|
||||
}
|
||||
|
||||
// New is a service.
|
||||
func New(s Store, a Agents, log *slog.Logger) *Service {
|
||||
if log == nil {
|
||||
log = slog.New(slog.DiscardHandler)
|
||||
}
|
||||
return &Service{store: s, agents: a, log: log, pieces: map[[2]string][]byte{}}
|
||||
}
|
||||
|
||||
// Every is how often queued jobs are looked at.
|
||||
var Every = 5 * time.Second
|
||||
|
||||
// Run sends queued jobs until ctx ends.
|
||||
func (s *Service) Run(ctx context.Context) {
|
||||
t := time.NewTicker(Every)
|
||||
defer t.Stop()
|
||||
for {
|
||||
s.Dispatch(ctx)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch sends each queued job whose node is here and not already
|
||||
// training, one per node.
|
||||
func (s *Service) Dispatch(ctx context.Context) {
|
||||
queued, err := s.store.QueuedTrainJobs(ctx)
|
||||
if err != nil || len(queued) == 0 {
|
||||
return
|
||||
}
|
||||
active, err := s.store.ActiveTrainJobs(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
busy := map[string]bool{}
|
||||
for _, j := range active {
|
||||
busy[j.Node] = true
|
||||
}
|
||||
for _, q := range queued {
|
||||
if busy[q.Node] || !s.agents.Connected(q.Node) {
|
||||
continue
|
||||
}
|
||||
j, err := s.store.TrainJob(ctx, q.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
err = s.agents.Send(j.Node, &llmgwv1.ControlMessage{Msg: &llmgwv1.ControlMessage_TrainStart{TrainStart: &llmgwv1.TrainStart{
|
||||
JobId: j.ID, BaseRepo: j.BaseRepo, BaseRevision: j.BaseRevision, Data: j.Data, Params: j.Params,
|
||||
GpuMemoryMib: int32(j.GPUMemoryMiB), Gguf: j.GGUF}}}) //nolint:gosec // MiB
|
||||
if err != nil {
|
||||
s.log.Warn("could not send a training job to its node", "job", j.ID, "node", j.Node, "err", err)
|
||||
continue
|
||||
}
|
||||
j.State, j.StartedAt, j.Message = store.TrainStarting, time.Now(), "sent to "+j.Node
|
||||
_ = s.store.UpdateTrainJob(ctx, j)
|
||||
busy[j.Node] = true
|
||||
s.log.Info("training job sent", "job", j.ID, "node", j.Node, "adapter", j.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// OnTrainStatus records how a job goes, as its node says.
|
||||
func (s *Service) OnTrainStatus(node string, st *llmgwv1.TrainStatus) {
|
||||
ctx := context.Background()
|
||||
j, err := s.store.TrainJob(ctx, st.GetJobId())
|
||||
if err != nil || j.Node != node || store.TrainOver(j.State) {
|
||||
return
|
||||
}
|
||||
j.State = st.GetState()
|
||||
if m := st.GetMessage(); m != "" || j.State == store.TrainDone {
|
||||
j.Message = m
|
||||
}
|
||||
if st.GetSteps() > 0 {
|
||||
j.Step, j.Steps = int(st.GetStep()), int(st.GetSteps())
|
||||
}
|
||||
if st.GetLoss() > 0 {
|
||||
j.Loss = st.GetLoss()
|
||||
}
|
||||
if p := int(st.GetPeakMib()); p > j.PeakMiB {
|
||||
j.PeakMiB = p
|
||||
}
|
||||
if store.TrainOver(j.State) {
|
||||
j.FinishedAt = time.Now()
|
||||
s.mu.Lock()
|
||||
for k := range s.pieces {
|
||||
if k[0] == j.ID {
|
||||
delete(s.pieces, k)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
s.log.Info("training job ended", "job", j.ID, "state", j.State, "message", j.Message)
|
||||
}
|
||||
if err := s.store.UpdateTrainJob(ctx, j); err != nil {
|
||||
s.log.Warn("could not record a training job's progress", "job", j.ID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// OnTrainArtifact gathers what a job made, and keeps it when it is whole.
|
||||
func (s *Service) OnTrainArtifact(node string, a *llmgwv1.TrainArtifact) {
|
||||
key := [2]string{a.GetJobId(), a.GetKind()}
|
||||
s.mu.Lock()
|
||||
if a.GetSeq() == 0 {
|
||||
s.pieces[key] = nil
|
||||
}
|
||||
s.pieces[key] = append(s.pieces[key], a.GetData()...)
|
||||
whole := s.pieces[key]
|
||||
if a.GetLast() {
|
||||
delete(s.pieces, key)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if !a.GetLast() {
|
||||
return
|
||||
}
|
||||
if err := s.store.PutTrainArtifact(context.Background(), key[0], key[1], whole); err != nil {
|
||||
s.log.Warn("could not keep what a training job made", "job", key[0], "kind", key[1], "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// OnNodeGone fails the jobs a node was running: its agent's stream ended,
|
||||
// and the job with it.
|
||||
func (s *Service) OnNodeGone(node string) {
|
||||
ctx := context.Background()
|
||||
active, err := s.store.ActiveTrainJobs(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, j := range active {
|
||||
if j.Node != node {
|
||||
continue
|
||||
}
|
||||
j.State, j.FinishedAt = store.TrainFailed, time.Now()
|
||||
j.Message = "the node's agent went away while it trained; start the job again"
|
||||
_ = s.store.UpdateTrainJob(ctx, j)
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel stops a job: one still queued is not sent, one running is stopped
|
||||
// on its node.
|
||||
func (s *Service) Cancel(ctx context.Context, id string) error {
|
||||
j, err := s.store.TrainJob(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if store.TrainOver(j.State) {
|
||||
return nil
|
||||
}
|
||||
if j.State != store.TrainQueued && s.agents.Connected(j.Node) {
|
||||
_ = s.agents.Send(j.Node, &llmgwv1.ControlMessage{Msg: &llmgwv1.ControlMessage_TrainCancel{
|
||||
TrainCancel: &llmgwv1.TrainCancel{JobId: id}}})
|
||||
}
|
||||
j.State, j.FinishedAt, j.Message = store.TrainCancelled, time.Now(), "cancelled"
|
||||
return s.store.UpdateTrainJob(ctx, j)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package train
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
type fakeAgents struct {
|
||||
mu sync.Mutex
|
||||
connected map[string]bool
|
||||
sent []*llmgwv1.ControlMessage
|
||||
}
|
||||
|
||||
func (f *fakeAgents) Send(_ string, m *llmgwv1.ControlMessage) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.sent = append(f.sent, m)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeAgents) Connected(n string) bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.connected[n]
|
||||
}
|
||||
|
||||
func setup(t *testing.T) (*Service, *store.DB, *fakeAgents) {
|
||||
t.Helper()
|
||||
s, err := store.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "s.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
a := &fakeAgents{connected: map[string]bool{}}
|
||||
return New(s, a, nil), s, a
|
||||
}
|
||||
|
||||
func job(t *testing.T, s *store.DB, id, node string) {
|
||||
t.Helper()
|
||||
if err := s.CreateTrainJob(context.Background(), store.TrainJob{ID: id, Name: "a-" + id, Model: "m", BaseRepo: "o/n",
|
||||
Node: node, GPUMemoryMiB: 2000, Data: []byte(`{"q":"x","a":"y"}`), Pairs: 1, GGUF: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAJobGoesToItsNodeWhenItIsHereAndFree(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, s, a := setup(t)
|
||||
job(t, s, "j1", "cuda01")
|
||||
job(t, s, "j2", "cuda01")
|
||||
svc.Dispatch(ctx)
|
||||
if len(a.sent) != 0 {
|
||||
t.Fatal("sent to a node that is not here")
|
||||
}
|
||||
a.connected["cuda01"] = true
|
||||
svc.Dispatch(ctx)
|
||||
svc.Dispatch(ctx)
|
||||
if len(a.sent) != 1 {
|
||||
t.Fatalf("sent %d jobs to one node", len(a.sent))
|
||||
}
|
||||
start := a.sent[0].GetTrainStart()
|
||||
if start.GetJobId() != "j1" || start.GetGpuMemoryMib() != 2000 || !start.GetGguf() || string(start.GetData()) == "" {
|
||||
t.Errorf("sent %+v", start)
|
||||
}
|
||||
if j, _ := s.TrainJob(ctx, "j1"); j.State != store.TrainStarting || j.StartedAt.IsZero() {
|
||||
t.Errorf("j1 %+v", j)
|
||||
}
|
||||
|
||||
// Progress, then what it made in pieces, then done: the next is sent.
|
||||
svc.OnTrainStatus("cuda01", &llmgwv1.TrainStatus{JobId: "j1", State: "running", Step: 5, Steps: 10, Loss: 0.8, PeakMib: 1900})
|
||||
svc.OnTrainArtifact("cuda01", &llmgwv1.TrainArtifact{JobId: "j1", Kind: "gguf", Seq: 0, Data: []byte("GG")})
|
||||
svc.OnTrainArtifact("cuda01", &llmgwv1.TrainArtifact{JobId: "j1", Kind: "gguf", Seq: 1, Data: []byte("UF"), Last: true})
|
||||
svc.OnTrainStatus("cuda01", &llmgwv1.TrainStatus{JobId: "j1", State: "done"})
|
||||
j, _ := s.TrainJob(ctx, "j1")
|
||||
if j.State != store.TrainDone || j.Step != 5 || j.PeakMiB != 1900 || j.Loss != 0.8 || j.FinishedAt.IsZero() {
|
||||
t.Errorf("j1 after %+v", j)
|
||||
}
|
||||
if b, _ := s.TrainArtifact(ctx, "j1", "gguf"); string(b) != "GGUF" {
|
||||
t.Errorf("artifact %q", b)
|
||||
}
|
||||
// A late message does not bring an ended job back.
|
||||
svc.OnTrainStatus("cuda01", &llmgwv1.TrainStatus{JobId: "j1", State: "running"})
|
||||
if j, _ := s.TrainJob(ctx, "j1"); j.State != store.TrainDone {
|
||||
t.Errorf("revived: %s", j.State)
|
||||
}
|
||||
// Nor does another node speak for it.
|
||||
svc.Dispatch(ctx)
|
||||
if len(a.sent) != 2 || a.sent[1].GetTrainStart().GetJobId() != "j2" {
|
||||
t.Fatalf("j2 not sent: %d", len(a.sent))
|
||||
}
|
||||
svc.OnTrainStatus("node01", &llmgwv1.TrainStatus{JobId: "j2", State: "done"})
|
||||
if j, _ := s.TrainJob(ctx, "j2"); j.State == store.TrainDone {
|
||||
t.Error("another node ended the job")
|
||||
}
|
||||
// The node goes: its job fails, saying why.
|
||||
svc.OnNodeGone("cuda01")
|
||||
if j, _ := s.TrainJob(ctx, "j2"); j.State != store.TrainFailed || j.Message == "" {
|
||||
t.Errorf("after the node went: %+v", j)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancellingAJob(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, s, a := setup(t)
|
||||
job(t, s, "j1", "cuda01")
|
||||
if err := svc.Cancel(ctx, "j1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.connected["cuda01"] = true
|
||||
svc.Dispatch(ctx)
|
||||
if len(a.sent) != 0 {
|
||||
t.Error("a cancelled job was sent")
|
||||
}
|
||||
job(t, s, "j2", "cuda01")
|
||||
svc.Dispatch(ctx)
|
||||
_ = svc.Cancel(ctx, "j2")
|
||||
if len(a.sent) != 2 || a.sent[1].GetTrainCancel().GetJobId() != "j2" {
|
||||
t.Errorf("the node was not told: %+v", a.sent)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ var Reference = []Block{
|
||||
{Key: "name", Type: "string", Doc: "Name is what clients ask for, like a model name.", Nested: ""},
|
||||
{Key: "source", Type: "string", Doc: "Source is the adapter: a GGUF file for llama.cpp; for vLLM a PEFT\nadapter directory (a mirror version ending in \"/\", a path on the node,\nor a Hugging Face repository id). Fetched by each node as the model's\nweights are.", Nested: ""},
|
||||
{Key: "scale", Type: "float64", Doc: "Scale is how strongly it is applied; zero takes 1.", Nested: ""},
|
||||
{Key: "owner", Type: "string", Doc: "Owner is the part of the tenancy tree the adapter belongs to —\norg:ID, team:ID, project:ID, user:ID or key:ID — and only callers in\nit see it: to anyone else it is a model that does not exist. An\nadapter trained on an organisation's documents carries some of what\nthey say in its weights. Empty is every caller the model allows.", Nested: ""},
|
||||
}},
|
||||
{Package: "config", Name: "Admission", Title: "Admission", Doc: "", Fields: []Field{
|
||||
{Key: "enabled", Type: "bool", Doc: "Enabled turns the layer on. Off, every request goes straight through,\nwhich is the v0.2.0 behaviour.", Nested: ""},
|
||||
@@ -19,6 +20,10 @@ var Reference = []Block{
|
||||
{Key: "queue_capacity_total", Type: "int", Doc: "QueueCapacityTotal bounds everything queued across bands. Reached, a\nrequest is not refused while a lower band has any queued: the newest of\nthe lowest band is turned away instead, with a 503 and Retry-After.\nZero takes half of all the bands' capacities together.", Nested: ""},
|
||||
{Key: "interval", Type: "time.Duration", Doc: "", Nested: ""},
|
||||
}},
|
||||
{Package: "config", Name: "AgentSettings", Title: "AgentSettings", Doc: "", Fields: []Field{
|
||||
{Key: "default", Type: "map[string]any", Doc: "Default is agent.yaml's keys for every node.", Nested: ""},
|
||||
{Key: "nodes", Type: "map[string]map[string]any", Doc: "Nodes are agent.yaml's keys for one node each, by node id, over\nDefault.", Nested: ""},
|
||||
}},
|
||||
{Package: "config", Name: "Auth", Title: "Auth", Doc: "", Fields: []Field{
|
||||
{Key: "enabled", Type: "bool", Doc: "", Nested: ""},
|
||||
{Key: "store", Type: "string", Doc: "Store is the SQLite file holding keys, tenancy and usage.", Nested: ""},
|
||||
@@ -372,6 +377,7 @@ var Reference = []Block{
|
||||
{Key: "mail", Type: "mail.Config", Doc: "Mail is where the gateway's few emails go out from: a link to set a\nforgotten password. Unset, none is sent and people ask an operator.", Nested: "mail.Config"},
|
||||
{Key: "serving", Type: "ServingSettings", Doc: "Serving stops and starts the whole cluster's service (llmgw stop, llmgw\nstart).", Nested: "config.ServingSettings"},
|
||||
{Key: "firewall", Type: "FirewallSettings", Doc: "Firewall turns llmgw's firewall on every machine on or off.", Nested: "config.FirewallSettings"},
|
||||
{Key: "agents", Type: "AgentSettings", Doc: "Agents are the nodes' agent settings: agent.yaml's keys but those an\nagent needs to start, for every node (default) and for one\n(nodes.<id>). Sent to the agents as they change; an agent whose\nsettings changed starts again on them, its engines serving\nthroughout.", Nested: "config.AgentSettings"},
|
||||
{Key: "firewall_networks", Type: "[]string", Doc: "FirewallNetworks are networks, besides those each machine reaches the\ncluster on, that llmgw's internal ports answer: a monitoring host on\nanother subnet, nodes behind a router. Written into every machine's\nfirewall by `server init`, `node add` and `server add`.", Nested: ""},
|
||||
{Key: "backup_recipients", Type: "[]string", Doc: "BackupRecipients encrypt every archive `llmgw server backup` writes, to\nwhoever holds one of their keys: age recipients (age1…) or SSH public\nkeys (ssh-ed25519 …, ssh-rsa …), as `age` takes them. An archive holds\nthe certificate authority's key and every secret in the store, and is\ncopied off the machine; encrypted, the copy is safe wherever it lands.\nEmpty writes archives unencrypted, readable by root only.", Nested: ""},
|
||||
{Key: "backup_copy_to", Type: "[]string", Doc: "BackupCopyTo is where each archive is copied once written, so it\nsurvives this machine: a directory (a mounted share, another disk) or\nssh://user@host[:port]/path, reached with BackupCopyKey and checked\nagainst /etc/llmgw/backup_known_hosts, which learns a host the first\ntime. The same number of archives is kept there as here. Refused\nwithout backup_recipients: an archive leaves the machine encrypted.", Nested: ""},
|
||||
|
||||
@@ -32,6 +32,7 @@ var Sources = []string{
|
||||
"internal/config/oidc.go",
|
||||
"internal/config/firewall.go",
|
||||
"internal/config/serving.go",
|
||||
"internal/config/agents.go",
|
||||
"internal/mail/mail.go",
|
||||
"internal/notify/notify.go",
|
||||
"internal/secrets/secrets.go",
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A store member that is nothing else runs no llmgw process: Patroni, etcd
|
||||
// and PostgreSQL only. What the cluster's settings say of its firewall it
|
||||
// reads from its own copy of the store, where the leading control plane
|
||||
// keeps it (the cluster_state table, key firewall), once a minute, by a
|
||||
// root timer running FollowScript. It turns llmgw's table off and on, and
|
||||
// lets in the networks the settings add; what `server init` let in stays.
|
||||
|
||||
// FollowScript is the script, as python3 runs it. LLMGW_FW_STATE and
|
||||
// LLMGW_FW_RULES replace the store and the rules file, for its tests.
|
||||
const FollowScript = `#!/usr/bin/python3
|
||||
# Written by llmgw: llmgw's firewall on this store member, as the cluster's
|
||||
# settings say. Run by llmgw-firewall-follow.timer.
|
||||
import ipaddress, json, os, subprocess, sys, tempfile
|
||||
|
||||
RULES = os.environ.get("LLMGW_FW_RULES", "` + RulesFile + `")
|
||||
|
||||
def run(*a):
|
||||
return subprocess.run(a, capture_output=True, text=True)
|
||||
|
||||
raw = os.environ.get("LLMGW_FW_STATE")
|
||||
if raw is None:
|
||||
r = run("runuser", "-u", "postgres", "--", "psql", "-d", "llmgw", "-tAqc",
|
||||
"SELECT value FROM cluster_state WHERE key = 'firewall'")
|
||||
# No table yet, or nothing told: a control plane older than this, or
|
||||
# none leading. Left as it is.
|
||||
if r.returncode != 0:
|
||||
sys.exit(0)
|
||||
raw = r.stdout
|
||||
if not raw.strip():
|
||||
sys.exit(0)
|
||||
st = json.loads(raw)
|
||||
present = run("nft", "list", "table", "inet", "` + Table + `").returncode == 0
|
||||
|
||||
if not st.get("enabled", True):
|
||||
if present:
|
||||
run("systemctl", "disable", "--now", "` + Unit + `")
|
||||
run("nft", "delete", "table", "inet", "` + Table + `")
|
||||
print("llmgw's firewall is off here")
|
||||
sys.exit(0)
|
||||
|
||||
if not os.path.exists(RULES):
|
||||
sys.exit(0)
|
||||
lines = open(RULES).read().split("\n")
|
||||
want = []
|
||||
for n in st.get("networks") or []:
|
||||
try:
|
||||
want.append(ipaddress.ip_network(n, strict=False))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def saddr(l):
|
||||
s = l.strip()
|
||||
return s.startswith("ip saddr {") or s.startswith("ip6 saddr {")
|
||||
|
||||
changed = False
|
||||
for fam, kw in ((4, "ip saddr {"), (6, "ip6 saddr {")):
|
||||
add = [str(n) for n in want if n.version == fam]
|
||||
if not add:
|
||||
continue
|
||||
same = [i for i, l in enumerate(lines) if l.strip().startswith(kw)]
|
||||
if same:
|
||||
i = same[0]
|
||||
l = lines[i]
|
||||
a, b = l.index("{") + 1, l.index("}")
|
||||
have = [x.strip() for x in l[a:b].split(",") if x.strip()]
|
||||
new = [x for x in add if x not in have]
|
||||
if new:
|
||||
lines[i] = l[:a] + " " + ", ".join(have + new) + " " + l[b:]
|
||||
changed = True
|
||||
continue
|
||||
other = [i for i, l in enumerate(lines) if saddr(l)]
|
||||
if not other:
|
||||
continue
|
||||
l = lines[other[0]]
|
||||
indent = l[:len(l) - len(l.lstrip())]
|
||||
lines.insert(other[0] + 1, indent + kw + " " + ", ".join(add) + " }" + l[l.index("}") + 1:])
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(RULES))
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write("\n".join(lines))
|
||||
if run("nft", "-c", "-f", tmp).returncode != 0:
|
||||
os.unlink(tmp)
|
||||
print("the settings' networks make a ruleset nft refuses; left as it was", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
os.chmod(tmp, 0o644)
|
||||
os.replace(tmp, RULES)
|
||||
if changed or not present:
|
||||
run("systemctl", "enable", "` + Unit + `")
|
||||
if run("nft", "-f", RULES).returncode != 0:
|
||||
sys.exit(1)
|
||||
print("llmgw's firewall is on here")
|
||||
`
|
||||
|
||||
// FollowerPath is where FollowScript is installed.
|
||||
const FollowerPath = "/usr/local/lib/llmgw/firewall-follow"
|
||||
|
||||
// FollowerTimer is the timer that runs it.
|
||||
const FollowerTimer = "llmgw-firewall-follow.timer"
|
||||
|
||||
const followService = `[Unit]
|
||||
Description=llmgw firewall: follow the cluster's settings on this store member
|
||||
After=postgresql.service patroni.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/python3 ` + FollowerPath + `
|
||||
`
|
||||
|
||||
const followTimer = `[Unit]
|
||||
Description=llmgw firewall: follow the cluster's settings on this store member
|
||||
|
||||
[Timer]
|
||||
OnBootSec=1min
|
||||
OnUnitActiveSec=1min
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
`
|
||||
|
||||
// InstallFollower puts FollowScript and its timer on a store member.
|
||||
func InstallFollower(ctx context.Context, r Runner) error {
|
||||
body := FollowScript + "\x00" + followService + "\x00" + followTimer
|
||||
out, err := r.Run(ctx, `set -e
|
||||
t=$(mktemp -d); trap 'rm -rf "$t"' EXIT
|
||||
cat > "$t/all"
|
||||
python3 - "$t" <<'PY'
|
||||
import sys
|
||||
d = sys.argv[1]
|
||||
s, svc, tim = open(d + "/all", "rb").read().split(b"\x00")
|
||||
open(d + "/script", "wb").write(s)
|
||||
open(d + "/service", "wb").write(svc)
|
||||
open(d + "/timer", "wb").write(tim)
|
||||
PY
|
||||
sudo install -d -m 755 /usr/local/lib/llmgw
|
||||
sudo install -m 755 "$t/script" `+FollowerPath+`
|
||||
sudo install -m 644 "$t/service" /etc/systemd/system/llmgw-firewall-follow.service
|
||||
sudo install -m 644 "$t/timer" /etc/systemd/system/`+FollowerTimer+`
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now `+FollowerTimer+` >/dev/null 2>&1`, []byte(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("installing %s: %v: %s", FollowerTimer, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// follow runs FollowScript against a rules file and a state, with nft and
|
||||
// systemctl replaced by scripts that log what they were asked and answer as
|
||||
// a machine whose table is loaded or not.
|
||||
func follow(t *testing.T, rules, state string, loaded bool) (string, string, string) {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("python3"); err != nil {
|
||||
t.Skip("no python3")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, "bin")
|
||||
_ = os.Mkdir(bin, 0o755)
|
||||
log := filepath.Join(dir, "log")
|
||||
list := "exit 1"
|
||||
if loaded {
|
||||
list = "exit 0"
|
||||
}
|
||||
for name, body := range map[string]string{
|
||||
"nft": `echo "nft $*" >> ` + log + "\n" + `[ "$1" = list ] && ` + list + "\nexit 0\n",
|
||||
"systemctl": `echo "systemctl $*" >> ` + log + "\nexit 0\n",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\n"+body), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
rf := filepath.Join(dir, "firewall.nft")
|
||||
if rules != "" {
|
||||
_ = os.WriteFile(rf, []byte(rules), 0o644)
|
||||
}
|
||||
script := filepath.Join(dir, "follow")
|
||||
_ = os.WriteFile(script, []byte(FollowScript), 0o755)
|
||||
cmd := exec.Command("python3", script)
|
||||
cmd.Env = append(os.Environ(), "PATH="+bin+":"+os.Getenv("PATH"), "LLMGW_FW_STATE="+state, "LLMGW_FW_RULES="+rf)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("%v: %s", err, out)
|
||||
}
|
||||
got, _ := os.ReadFile(rf)
|
||||
calls, _ := os.ReadFile(log)
|
||||
return string(out), string(got), string(calls)
|
||||
}
|
||||
|
||||
var memberRules = Script(Spec{Ports: []Port{Postgres, EtcdClient}, Allow: mustPrefixes("10.0.0.0/24")})
|
||||
|
||||
func TestAStoreMemberFollowsTheSettings(t *testing.T) {
|
||||
// Off: the table goes, the rules stay for later.
|
||||
out, rules, calls := follow(t, memberRules, `{"enabled":false}`, true)
|
||||
if !strings.Contains(out, "off") || !strings.Contains(calls, "nft delete table inet llmgw") ||
|
||||
!strings.Contains(calls, "systemctl disable --now "+Unit) || rules != memberRules {
|
||||
t.Errorf("off: %q %q", out, calls)
|
||||
}
|
||||
// On again: loaded from the rules kept.
|
||||
out, _, calls = follow(t, memberRules, `{"enabled":true}`, false)
|
||||
if !strings.Contains(out, "on") || !strings.Contains(calls, "nft -f") {
|
||||
t.Errorf("on: %q %q", out, calls)
|
||||
}
|
||||
// On and loaded, nothing new: nothing done.
|
||||
out, _, calls = follow(t, memberRules, `{"enabled":true,"networks":["10.0.0.0/24"]}`, true)
|
||||
if out != "" || strings.Contains(calls, "nft -f") {
|
||||
t.Errorf("unchanged: %q %q", out, calls)
|
||||
}
|
||||
// Networks the settings add, of both families, are let in beside what
|
||||
// init let in; the result reads back as the same ports.
|
||||
_, rules, calls = follow(t, memberRules, `{"enabled":true,"networks":["192.168.5.0/24","fd00::/64","nonsense"]}`, true)
|
||||
if !strings.Contains(calls, "nft -c -f") || !strings.Contains(calls, "nft -f") {
|
||||
t.Errorf("not checked and loaded: %q", calls)
|
||||
}
|
||||
back := parse(rules)
|
||||
var nets []string
|
||||
for _, p := range back.Allow {
|
||||
nets = append(nets, p.String())
|
||||
}
|
||||
if strings.Join(nets, ",") != "10.0.0.0/24,192.168.5.0/24,fd00::/64" || len(back.Ports) != 2 {
|
||||
t.Errorf("rules now:\n%s", rules)
|
||||
}
|
||||
// Nothing told yet, or no rules: left alone.
|
||||
if out, _, calls := follow(t, memberRules, "", true); out != "" || calls != "" {
|
||||
t.Errorf("nothing told: %q %q", out, calls)
|
||||
}
|
||||
if out, _, calls := follow(t, "", `{"enabled":true}`, false); out != "" || strings.Contains(calls, "nft -f") {
|
||||
t.Errorf("no rules: %q %q", out, calls)
|
||||
}
|
||||
}
|
||||
|
||||
func mustPrefixes(ss ...string) []netip.Prefix {
|
||||
var out []netip.Prefix
|
||||
for _, s := range ss {
|
||||
out = append(out, netip.MustParsePrefix(s))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
// Package chunk splits extracted text into the pieces a knowledge base keeps
|
||||
// and searches (BACKLOG F2).
|
||||
//
|
||||
// A chunk is a few paragraphs under the headings they sit beneath: about 500
|
||||
// tokens, which an embedding model reads whole and a prompt can carry several
|
||||
// of. Paragraphs are not cut while they fit; a section starts a new chunk
|
||||
// when the current one is already a fair size, so a chunk rarely mixes two
|
||||
// topics; and the last paragraph of a chunk is repeated at the start of the
|
||||
// next, so an answer that spans the boundary is found whole in one of them.
|
||||
package chunk
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Options size the chunks. Zero values take the defaults.
|
||||
type Options struct {
|
||||
// MaxChars is the most characters in a chunk's body: 1800, about 500
|
||||
// tokens of Turkish or English.
|
||||
MaxChars int
|
||||
// Overlap is the most characters of the previous chunk repeated at the
|
||||
// start of the next: 300.
|
||||
Overlap int
|
||||
}
|
||||
|
||||
func (o Options) max() int {
|
||||
if o.MaxChars > 0 {
|
||||
return o.MaxChars
|
||||
}
|
||||
return 1800
|
||||
}
|
||||
|
||||
func (o Options) overlap() int {
|
||||
if o.Overlap >= 0 && o.MaxChars > 0 {
|
||||
return min(o.Overlap, o.max()/2)
|
||||
}
|
||||
return 300
|
||||
}
|
||||
|
||||
// Chunk is one piece: the headings above it and its text.
|
||||
type Chunk struct {
|
||||
// Headings is the path of headings the chunk starts under, widest first.
|
||||
Headings []string
|
||||
Body string
|
||||
}
|
||||
|
||||
// Text is what is stored and embedded: the heading path, then the body. The
|
||||
// path is part of it so that "how many days?" under "Annual leave" finds the
|
||||
// paragraph that says "twenty days" without naming leave.
|
||||
func (c Chunk) Text() string {
|
||||
if len(c.Headings) == 0 {
|
||||
return c.Body
|
||||
}
|
||||
return strings.Join(c.Headings, " › ") + "\n\n" + c.Body
|
||||
}
|
||||
|
||||
// Split cuts Markdown-shaped text into chunks.
|
||||
func Split(text string, o Options) []Chunk {
|
||||
max, overlap := o.max(), o.overlap()
|
||||
var out []Chunk
|
||||
type head struct {
|
||||
level int
|
||||
title string
|
||||
}
|
||||
var stack []head // the headings in force, widest first
|
||||
titles := func() []string {
|
||||
out := make([]string, len(stack))
|
||||
for i, h := range stack {
|
||||
out[i] = h.title
|
||||
}
|
||||
return out
|
||||
}
|
||||
var cur []string // blocks of the chunk being built
|
||||
var curHeads []string
|
||||
size := 0
|
||||
// carried is the overlap at the start of cur: it does not count as the
|
||||
// chunk having content of its own.
|
||||
carried := 0
|
||||
|
||||
emit := func(keepOverlap bool) {
|
||||
if size-carried <= 0 {
|
||||
cur, size, carried = nil, 0, 0
|
||||
return
|
||||
}
|
||||
out = append(out, Chunk{Headings: curHeads, Body: strings.Join(cur, "\n\n")})
|
||||
last := cur[len(cur)-1]
|
||||
cur, size, carried = nil, 0, 0
|
||||
if keepOverlap && overlap > 0 && runes(last) <= overlap {
|
||||
cur, size, carried = []string{last}, runes(last), runes(last)
|
||||
}
|
||||
}
|
||||
add := func(b string) {
|
||||
if len(cur) == 0 || size == carried {
|
||||
curHeads = titles()
|
||||
}
|
||||
if size > 0 && size+2+runes(b) > max {
|
||||
emit(true)
|
||||
curHeads = titles()
|
||||
}
|
||||
cur = append(cur, b)
|
||||
size += runes(b) + 2
|
||||
}
|
||||
|
||||
for _, b := range blocks(text) {
|
||||
if lvl, title := heading(b); lvl > 0 {
|
||||
// A new section starts a new chunk once this one is worth
|
||||
// keeping by itself; otherwise the heading goes into it.
|
||||
if size-carried >= max/3 {
|
||||
emit(false)
|
||||
}
|
||||
for len(stack) > 0 && stack[len(stack)-1].level >= lvl {
|
||||
stack = stack[:len(stack)-1]
|
||||
}
|
||||
stack = append(stack, head{lvl, title})
|
||||
if size-carried > 0 {
|
||||
add(b)
|
||||
} else {
|
||||
// Nothing of its own yet: the chunk starts under this heading.
|
||||
cur, size, carried = nil, 0, 0
|
||||
curHeads = titles()
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, piece := range fit(b, max) {
|
||||
add(piece)
|
||||
}
|
||||
}
|
||||
emit(false)
|
||||
return out
|
||||
}
|
||||
|
||||
func runes(s string) int { return utf8.RuneCountInString(s) }
|
||||
|
||||
// heading reads a Markdown ATX heading line: its level and title.
|
||||
func heading(b string) (int, string) {
|
||||
if strings.Contains(b, "\n") {
|
||||
return 0, ""
|
||||
}
|
||||
n := 0
|
||||
for n < len(b) && b[n] == '#' {
|
||||
n++
|
||||
}
|
||||
if n == 0 || n > 6 || n >= len(b) || b[n] != ' ' {
|
||||
return 0, ""
|
||||
}
|
||||
return n, strings.TrimSpace(strings.TrimRight(strings.TrimSpace(b[n:]), "#"))
|
||||
}
|
||||
|
||||
// blocks splits text on blank lines, keeping fenced code blocks whole.
|
||||
func blocks(text string) []string {
|
||||
var out []string
|
||||
var b []string
|
||||
fenced := false
|
||||
flush := func() {
|
||||
s := strings.TrimSpace(strings.Join(b, "\n"))
|
||||
if s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
b = nil
|
||||
}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
t := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(t, "```") || strings.HasPrefix(t, "~~~") {
|
||||
fenced = !fenced
|
||||
}
|
||||
if !fenced && t == "" {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
// A heading line is a block of its own even without blank lines
|
||||
// around it.
|
||||
if !fenced && len(b) > 0 {
|
||||
if lvl, _ := heading(t); lvl > 0 {
|
||||
flush()
|
||||
}
|
||||
}
|
||||
b = append(b, strings.TrimRight(line, " \t"))
|
||||
if !fenced {
|
||||
if lvl, _ := heading(t); lvl > 0 {
|
||||
flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
|
||||
// fit cuts a block longer than max: at line ends, then sentence ends, then
|
||||
// spaces, and only as a last resort mid-word.
|
||||
func fit(b string, max int) []string {
|
||||
if runes(b) <= max {
|
||||
return []string{b}
|
||||
}
|
||||
for _, split := range []func(string) []string{lines, sentences, words} {
|
||||
parts := split(b)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
var out []string
|
||||
var cur strings.Builder
|
||||
for _, p := range parts {
|
||||
if cur.Len() > 0 && runes(cur.String())+runes(p) > max {
|
||||
out = append(out, strings.TrimSpace(cur.String()))
|
||||
cur.Reset()
|
||||
}
|
||||
if runes(p) > max {
|
||||
out = append(out, fit(strings.TrimSpace(p), max)...)
|
||||
continue
|
||||
}
|
||||
cur.WriteString(p)
|
||||
}
|
||||
if strings.TrimSpace(cur.String()) != "" {
|
||||
out = append(out, strings.TrimSpace(cur.String()))
|
||||
}
|
||||
return out
|
||||
}
|
||||
// One word longer than a chunk: a hash, a line of base64.
|
||||
r := []rune(b)
|
||||
var out []string
|
||||
for len(r) > max {
|
||||
out, r = append(out, string(r[:max])), r[max:]
|
||||
}
|
||||
return append(out, string(r))
|
||||
}
|
||||
|
||||
// lines splits keeping each line's newline.
|
||||
func lines(s string) []string { return strings.SplitAfter(s, "\n") }
|
||||
|
||||
// sentences splits after ., ? or ! followed by a space, keeping both.
|
||||
func sentences(s string) []string {
|
||||
var out []string
|
||||
start := 0
|
||||
r := []rune(s)
|
||||
for i := 0; i < len(r)-1; i++ {
|
||||
if (r[i] == '.' || r[i] == '?' || r[i] == '!' || r[i] == '…') && unicode.IsSpace(r[i+1]) {
|
||||
out = append(out, string(r[start:i+2]))
|
||||
start = i + 2
|
||||
}
|
||||
}
|
||||
if start < len(r) {
|
||||
out = append(out, string(r[start:]))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func words(s string) []string { return strings.SplitAfter(s, " ") }
|
||||
@@ -0,0 +1,106 @@
|
||||
package chunk
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func para(word string, n int) string { return strings.TrimSpace(strings.Repeat(word+" ", n)) }
|
||||
|
||||
func TestShortTextIsOneChunkUnderItsHeadings(t *testing.T) {
|
||||
cs := Split("# İzin\n\n## Yıllık\n\nYirmi gün.\n\nBayramlar ayrı.\n", Options{})
|
||||
if len(cs) != 1 {
|
||||
t.Fatalf("chunks %+v", cs)
|
||||
}
|
||||
if got := cs[0].Text(); got != "İzin › Yıllık\n\nYirmi gün.\n\nBayramlar ayrı." {
|
||||
t.Errorf("text %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunksStayUnderTheLimitAndOverlap(t *testing.T) {
|
||||
var b strings.Builder
|
||||
for i := range 20 {
|
||||
b.WriteString(para("kelime"+string(rune('a'+i)), 30) + "\n\n")
|
||||
}
|
||||
cs := Split(b.String(), Options{MaxChars: 600, Overlap: 300})
|
||||
if len(cs) < 5 {
|
||||
t.Fatalf("only %d chunks", len(cs))
|
||||
}
|
||||
for i, c := range cs {
|
||||
if n := utf8.RuneCountInString(c.Body); n > 600 {
|
||||
t.Errorf("chunk %d has %d characters", i, n)
|
||||
}
|
||||
if i > 0 {
|
||||
prev := strings.Split(cs[i-1].Body, "\n\n")
|
||||
if !strings.HasPrefix(c.Body, prev[len(prev)-1]) {
|
||||
t.Errorf("chunk %d does not start with the end of chunk %d", i, i-1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestASectionStartsAChunkOnceTheLastIsWorthKeeping(t *testing.T) {
|
||||
text := "# A\n\n" + para("a", 400) + "\n\n# B\n\nkısa\n\n# C\n\nde kısa\n"
|
||||
cs := Split(text, Options{MaxChars: 1800})
|
||||
if len(cs) != 2 {
|
||||
t.Fatalf("chunks %+v", cs)
|
||||
}
|
||||
if cs[1].Headings[0] != "B" || !strings.Contains(cs[1].Body, "# C") || !strings.Contains(cs[1].Body, "de kısa") {
|
||||
t.Errorf("small sections were not kept together: %+v", cs[1])
|
||||
}
|
||||
// The overlap does not run into a new section.
|
||||
if strings.HasPrefix(cs[1].Body, "a a") {
|
||||
t.Errorf("section B starts with section A's text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadingLevelsReplaceTheirSiblings(t *testing.T) {
|
||||
text := "## A\n\n### A1\n\n" + para("x", 300) + "\n\n## B\n\n" + para("y", 300) + "\n"
|
||||
cs := Split(text, Options{MaxChars: 700})
|
||||
last := cs[len(cs)-1]
|
||||
if strings.Join(last.Headings, "/") != "B" {
|
||||
t.Errorf("headings after a sibling: %v", last.Headings)
|
||||
}
|
||||
if strings.Join(cs[0].Headings, "/") != "A/A1" {
|
||||
t.Errorf("first headings %v", cs[0].Headings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLongParagraphsAreCutAtSentencesThenWords(t *testing.T) {
|
||||
long := strings.Repeat("Bu bir cümledir. ", 200)
|
||||
for _, c := range Split(long, Options{MaxChars: 500}) {
|
||||
if utf8.RuneCountInString(c.Body) > 500 {
|
||||
t.Errorf("chunk of %d", utf8.RuneCountInString(c.Body))
|
||||
}
|
||||
if !strings.HasSuffix(c.Body, ".") {
|
||||
t.Errorf("cut mid-sentence: …%q", c.Body[len(c.Body)-20:])
|
||||
}
|
||||
}
|
||||
word := strings.Repeat("ş", 1200)
|
||||
cs := Split(word, Options{MaxChars: 500})
|
||||
total := 0
|
||||
for _, c := range cs {
|
||||
total += utf8.RuneCountInString(c.Body)
|
||||
if !utf8.ValidString(c.Body) {
|
||||
t.Error("a multibyte character was cut")
|
||||
}
|
||||
}
|
||||
if total != 1200 {
|
||||
t.Errorf("a long word lost characters: %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeFencesStayWhole(t *testing.T) {
|
||||
text := "Örnek:\n\n```go\nfunc a() {\n\n\treturn\n}\n```\n\nSon.\n"
|
||||
cs := Split(text, Options{})
|
||||
if len(cs) != 1 || !strings.Contains(cs[0].Body, "func a() {\n\n\treturn\n}") {
|
||||
t.Errorf("chunks %+v", cs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNothingToSayIsNoChunk(t *testing.T) {
|
||||
if cs := Split("# Başlık\n\n## Alt\n", Options{}); len(cs) != 0 {
|
||||
t.Errorf("headings alone made %+v", cs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package extract
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/quotedprintable"
|
||||
"net/mail"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// maxArchiveMembers is the most members of one archive read; past it the
|
||||
// rest are reported skipped.
|
||||
const maxArchiveMembers = 10000
|
||||
|
||||
// maxArchiveBytes is the most an archive's members are inflated to in all.
|
||||
const maxArchiveBytes = 2 << 30
|
||||
|
||||
// zipParts reads every member of a zip archive this can read, each a part
|
||||
// named by its path inside.
|
||||
func zipParts(ctx context.Context, name string, b []byte, o Options, depth int) ([]Part, error) {
|
||||
zr, err := openZip(b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var parts []Part
|
||||
var total int64
|
||||
for i, f := range zr.File {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return parts, err
|
||||
}
|
||||
if f.FileInfo().IsDir() || skipName(f.Name) {
|
||||
continue
|
||||
}
|
||||
mname := memberName(f.Name)
|
||||
if i >= maxArchiveMembers {
|
||||
parts = append(parts, Part{Name: mname, Err: fmt.Errorf("%w: past the first %d members", ErrUnsupported, maxArchiveMembers)})
|
||||
continue
|
||||
}
|
||||
if !Known(f.Name) {
|
||||
continue
|
||||
}
|
||||
if int64(f.UncompressedSize64) > o.maxBytes() {
|
||||
parts = append(parts, Part{Name: mname, Err: fmt.Errorf("%w: %d MiB is over the limit", ErrUnsupported, f.UncompressedSize64>>20)})
|
||||
continue
|
||||
}
|
||||
total += int64(f.UncompressedSize64)
|
||||
if total > maxArchiveBytes {
|
||||
parts = append(parts, Part{Name: mname, Err: fmt.Errorf("%w: the archive inflates past %d GiB", ErrUnsupported, maxArchiveBytes>>30)})
|
||||
break
|
||||
}
|
||||
data, err := readMember(f)
|
||||
if err != nil {
|
||||
parts = append(parts, Part{Name: mname, Err: err})
|
||||
continue
|
||||
}
|
||||
inner, err := extractNested(ctx, mname, data, o, depth+1)
|
||||
parts = append(parts, nest(mname, inner, err)...)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return nil, ErrNoText
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
func extractNested(ctx context.Context, name string, data []byte, o Options, depth int) ([]Part, error) {
|
||||
return extractDepth(ctx, name, data, o, depth)
|
||||
}
|
||||
|
||||
// nest names the parts of a member after it: "a/b.pdf", "a/c.zip!/d.txt".
|
||||
func nest(name string, parts []Part, err error) []Part {
|
||||
if err != nil {
|
||||
return []Part{{Name: name, Err: err}}
|
||||
}
|
||||
out := make([]Part, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p.Name == "" {
|
||||
p.Name = name
|
||||
} else {
|
||||
p.Name = name + "!/" + p.Name
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// memberName is a member's path made safe to name a document: forward
|
||||
// slashes, no leading slash or dot segments.
|
||||
func memberName(n string) string {
|
||||
n = strings.ReplaceAll(n, `\`, "/")
|
||||
n = path.Clean("/" + n)
|
||||
return strings.TrimPrefix(n, "/")
|
||||
}
|
||||
|
||||
// skipName is what systems leave in archives that is not anybody's
|
||||
// document: macOS resource forks, Office lock files, thumbnails.
|
||||
func skipName(n string) bool {
|
||||
base := path.Base(strings.ReplaceAll(n, `\`, "/"))
|
||||
return strings.HasPrefix(n, "__MACOSX/") || strings.HasPrefix(base, "._") || strings.HasPrefix(base, "~$") ||
|
||||
base == ".DS_Store" || base == "Thumbs.db" || base == "desktop.ini"
|
||||
}
|
||||
|
||||
// mailParts reads an e-mail (.eml): the message — its headers and the plain
|
||||
// text, or the HTML when there is no plain text — and each attachment this
|
||||
// can read, named by its file name.
|
||||
func mailParts(ctx context.Context, name string, b []byte, o Options, depth int) ([]Part, error) {
|
||||
m, err := mail.ReadMessage(bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dec := new(mime.WordDecoder)
|
||||
dec.CharsetReader = func(cs string, in io.Reader) (io.Reader, error) {
|
||||
all, err := io.ReadAll(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return strings.NewReader(decodeCharset(all, cs)), nil
|
||||
}
|
||||
header := func(k string) string {
|
||||
v := m.Header.Get(k)
|
||||
if d, err := dec.DecodeHeader(v); err == nil {
|
||||
return d
|
||||
}
|
||||
return v
|
||||
}
|
||||
var plain, htmlBody []string
|
||||
var attachments []Part
|
||||
err = walkMIME(m.Header.Get("Content-Type"), m.Header.Get("Content-Transfer-Encoding"), m.Body, 0,
|
||||
func(ctype, filename string, params map[string]string, body []byte) {
|
||||
if filename != "" {
|
||||
if d, err := dec.DecodeHeader(filename); err == nil {
|
||||
filename = d
|
||||
}
|
||||
if Known(filename) {
|
||||
inner, err := extractNested(ctx, filename, body, o, depth+1)
|
||||
attachments = append(attachments, nest(memberName(filename), inner, err)...)
|
||||
}
|
||||
return
|
||||
}
|
||||
switch ctype {
|
||||
case "text/plain":
|
||||
plain = append(plain, decodeCharset(body, params["charset"]))
|
||||
case "text/html":
|
||||
if t, err := htmlFrom(strings.NewReader(decodeCharset(body, params["charset"]))); err == nil {
|
||||
htmlBody = append(htmlBody, t)
|
||||
}
|
||||
case "message/rfc822":
|
||||
if inner, err := mailParts(ctx, "", body, o, depth+1); err == nil {
|
||||
attachments = append(attachments, nest("message", inner, nil)...)
|
||||
}
|
||||
}
|
||||
})
|
||||
if err != nil && len(plain) == 0 && len(htmlBody) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
var text strings.Builder
|
||||
if s := header("Subject"); s != "" {
|
||||
text.WriteString("# " + s + "\n\n")
|
||||
}
|
||||
for _, k := range []string{"From", "To", "Cc", "Date"} {
|
||||
if v := header(k); v != "" {
|
||||
text.WriteString(k + ": " + v + "\n")
|
||||
}
|
||||
}
|
||||
text.WriteString("\n")
|
||||
body := plain
|
||||
if len(body) == 0 {
|
||||
body = htmlBody
|
||||
}
|
||||
for _, p := range body {
|
||||
text.WriteString(p + "\n\n")
|
||||
}
|
||||
parts := []Part{{Text: Clean(text.String())}}
|
||||
parts = append(parts, attachments...)
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
// transferDecoder undoes a MIME part's Content-Transfer-Encoding.
|
||||
func transferDecoder(cte string, r io.Reader) io.Reader {
|
||||
switch strings.ToLower(strings.TrimSpace(cte)) {
|
||||
case "base64":
|
||||
return base64.NewDecoder(base64.StdEncoding, newlineStripper{r})
|
||||
case "quoted-printable":
|
||||
return quotedprintable.NewReader(r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// newlineStripper drops CR and LF, which base64 in mail is wrapped with and
|
||||
// encoding/base64 does not skip in every position.
|
||||
type newlineStripper struct{ r io.Reader }
|
||||
|
||||
func (n newlineStripper) Read(p []byte) (int, error) {
|
||||
for {
|
||||
k, err := n.r.Read(p)
|
||||
j := 0
|
||||
for _, c := range p[:k] {
|
||||
if c != '\r' && c != '\n' {
|
||||
p[j] = c
|
||||
j++
|
||||
}
|
||||
}
|
||||
if j > 0 || err != nil {
|
||||
if errors.Is(err, io.EOF) && j > 0 {
|
||||
return j, nil
|
||||
}
|
||||
return j, err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
// Package extract turns documents into plain text for knowledge bases
|
||||
// (BACKLOG F2), on the machine that holds them.
|
||||
//
|
||||
// The text it produces is Markdown-shaped: headings as "# ", "## ", list
|
||||
// items as "- ", paragraphs separated by a blank line. The chunker splits on
|
||||
// exactly those, so a format that knows its headings (DOCX styles, HTML h1–h6,
|
||||
// ODT outline levels, slides, sheets) says so, and its chunks carry them.
|
||||
//
|
||||
// Built-in formats are pure Go, so the client builds for every system without
|
||||
// a C toolchain. What Go cannot read — Word 97 files, scanned pages — goes to
|
||||
// a helper when one is on the machine (LibreOffice, Tesseract, Poppler), and
|
||||
// is otherwise skipped with a sentence saying which helper would read it.
|
||||
package extract
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// ErrUnsupported is a file this cannot read, with the reason wrapped around
|
||||
// it ("a .doc needs LibreOffice").
|
||||
var ErrUnsupported = errors.New("unsupported")
|
||||
|
||||
// ErrNoText is a file that was read and held no text: a scanned PDF without
|
||||
// an OCR helper, an empty sheet.
|
||||
var ErrNoText = errors.New("no text in it")
|
||||
|
||||
// Part is the text of one document. A file is one part; an archive or a mail
|
||||
// with attachments is several, each named by its path inside, after the
|
||||
// outer file's name and "!/".
|
||||
type Part struct {
|
||||
// Name is "" for the file itself, or the path inside it.
|
||||
Name string
|
||||
Text string
|
||||
// Err is why this part was skipped, when it was; Text is then empty.
|
||||
Err error
|
||||
}
|
||||
|
||||
// Options are the limits and helpers extraction uses.
|
||||
type Options struct {
|
||||
Helpers Helpers
|
||||
// MaxBytes is the largest file, or member of an archive, read; 0 is
|
||||
// 256 MiB.
|
||||
MaxBytes int64
|
||||
// OCRLanguages are Tesseract's languages; "" is "tur+eng".
|
||||
OCRLanguages string
|
||||
}
|
||||
|
||||
func (o Options) maxBytes() int64 {
|
||||
if o.MaxBytes > 0 {
|
||||
return o.MaxBytes
|
||||
}
|
||||
return 256 << 20
|
||||
}
|
||||
|
||||
// File extracts a file on disk.
|
||||
func File(ctx context.Context, name string, o Options) ([]Part, error) {
|
||||
st, err := os.Stat(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if st.Size() > o.maxBytes() {
|
||||
return nil, fmt.Errorf("%w: %d MiB is over the %d MiB limit", ErrUnsupported, st.Size()>>20, o.maxBytes()>>20)
|
||||
}
|
||||
data, err := os.ReadFile(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Bytes(ctx, filepath.Base(name), data, o)
|
||||
}
|
||||
|
||||
// Bytes extracts a document held in memory; name gives its format.
|
||||
func Bytes(ctx context.Context, name string, data []byte, o Options) ([]Part, error) {
|
||||
return extractDepth(ctx, name, data, o, 0)
|
||||
}
|
||||
|
||||
// maxDepth is how many archives deep members are followed: a zip in a mail
|
||||
// in a zip, and no further.
|
||||
const maxDepth = 3
|
||||
|
||||
func extractDepth(ctx context.Context, name string, data []byte, o Options, depth int) ([]Part, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ext := Ext(name)
|
||||
if f, ok := containers[ext]; ok {
|
||||
if depth >= maxDepth {
|
||||
return nil, fmt.Errorf("%w: archives nested more than %d deep", ErrUnsupported, maxDepth)
|
||||
}
|
||||
return f(ctx, name, data, o, depth)
|
||||
}
|
||||
text, err := one(ctx, name, ext, data, o)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
text = Clean(text)
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil, ErrNoText
|
||||
}
|
||||
return []Part{{Text: text}}, nil
|
||||
}
|
||||
|
||||
// one extracts a single document.
|
||||
func one(ctx context.Context, name, ext string, data []byte, o Options) (string, error) {
|
||||
if f, ok := builtin[ext]; ok {
|
||||
text, err := f(data)
|
||||
if err == nil && strings.TrimSpace(text) != "" {
|
||||
return text, nil
|
||||
}
|
||||
// A PDF the Go reader cannot parse, or one with no text layer, is
|
||||
// what the helpers are for.
|
||||
if alt, ok := fallback[ext]; ok {
|
||||
if t, aerr := alt(ctx, name, data, o); aerr == nil {
|
||||
return t, nil
|
||||
} else if err == nil {
|
||||
err = aerr
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
err = ErrNoText
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if alt, ok := fallback[ext]; ok {
|
||||
return alt(ctx, name, data, o)
|
||||
}
|
||||
if looksLikeText(data) {
|
||||
return decodeText(data), nil
|
||||
}
|
||||
if o.Helpers.Office != "" {
|
||||
return o.Helpers.officeToText(ctx, name, data, o)
|
||||
}
|
||||
return "", fmt.Errorf("%w: no reader for %q files (LibreOffice, if installed, may read it)", ErrUnsupported, ext)
|
||||
}
|
||||
|
||||
// Ext is a name's extension, lower case and without the dot.
|
||||
func Ext(name string) string {
|
||||
return strings.TrimPrefix(strings.ToLower(path.Ext(strings.ReplaceAll(name, `\`, "/"))), ".")
|
||||
}
|
||||
|
||||
// Supported lists the extensions read without a helper, and those read with
|
||||
// one, for --help and the docs.
|
||||
func Supported() (builtIn, withHelper []string) {
|
||||
for e := range builtin {
|
||||
builtIn = append(builtIn, e)
|
||||
}
|
||||
for e := range containers {
|
||||
builtIn = append(builtIn, e)
|
||||
}
|
||||
for e := range textExts {
|
||||
builtIn = append(builtIn, e)
|
||||
}
|
||||
for e := range fallback {
|
||||
if _, ok := builtin[e]; !ok {
|
||||
withHelper = append(withHelper, e)
|
||||
}
|
||||
}
|
||||
sort.Strings(builtIn)
|
||||
sort.Strings(withHelper)
|
||||
return builtIn, withHelper
|
||||
}
|
||||
|
||||
// Known reports whether a name's extension is one this reads, with or without
|
||||
// a helper, or one of the text formats.
|
||||
func Known(name string) bool {
|
||||
e := Ext(name)
|
||||
_, a := builtin[e]
|
||||
_, b := containers[e]
|
||||
_, c := fallback[e]
|
||||
return a || b || c || textExts[e]
|
||||
}
|
||||
|
||||
var (
|
||||
builtin map[string]func([]byte) (string, error)
|
||||
containers map[string]func(context.Context, string, []byte, Options, int) ([]Part, error)
|
||||
fallback map[string]func(context.Context, string, []byte, Options) (string, error)
|
||||
)
|
||||
|
||||
func init() {
|
||||
builtin = map[string]func([]byte) (string, error){
|
||||
"pdf": pdfText,
|
||||
"html": htmlText, "htm": htmlText, "xhtml": htmlText, "mht": mhtmlText, "mhtml": mhtmlText,
|
||||
"docx": docxText, "docm": docxText, "dotx": docxText, "dotm": docxText,
|
||||
"xlsx": xlsxText, "xlsm": xlsxText, "xltx": xlsxText, "xltm": xlsxText,
|
||||
"pptx": pptxText, "pptm": pptxText, "ppsx": pptxText, "ppsm": pptxText, "potx": pptxText,
|
||||
"odt": odfText, "ott": odfText, "ods": odfText, "ots": odfText, "odp": odfText, "otp": odfText,
|
||||
"fodt": flatODFText, "fods": flatODFText, "fodp": flatODFText,
|
||||
"rtf": rtfText,
|
||||
"epub": epubText,
|
||||
"ipynb": notebookText,
|
||||
"srt": subtitleText, "vtt": subtitleText,
|
||||
}
|
||||
for e := range textExts {
|
||||
builtin[e] = func(b []byte) (string, error) { return decodeText(b), nil }
|
||||
}
|
||||
containers = map[string]func(context.Context, string, []byte, Options, int) ([]Part, error){
|
||||
"zip": zipParts,
|
||||
"eml": mailParts,
|
||||
}
|
||||
fallback = map[string]func(context.Context, string, []byte, Options) (string, error){
|
||||
"pdf": pdfWithHelpers,
|
||||
"rtf": viaOffice,
|
||||
}
|
||||
for _, e := range officeOnly {
|
||||
fallback[e] = viaOffice
|
||||
}
|
||||
for _, e := range imageExts {
|
||||
fallback[e] = viaOCR
|
||||
}
|
||||
}
|
||||
|
||||
// textExts are formats that are text already: read as they are, decoded.
|
||||
var textExts = map[string]bool{
|
||||
"txt": true, "text": true, "md": true, "markdown": true, "mdx": true, "rst": true, "adoc": true,
|
||||
"asciidoc": true, "org": true, "tex": true, "log": true, "csv": true, "tsv": true, "json": true,
|
||||
"jsonl": true, "ndjson": true, "yaml": true, "yml": true, "toml": true, "ini": true, "cfg": true,
|
||||
"conf": true, "properties": true, "env": true, "xml": true, "svg": true, "sql": true,
|
||||
"go": true, "py": true, "js": true, "mjs": true, "cjs": true, "ts": true, "tsx": true, "jsx": true,
|
||||
"java": true, "kt": true, "kts": true, "scala": true, "c": true, "h": true, "cc": true, "cpp": true,
|
||||
"cxx": true, "hpp": true, "hh": true, "cs": true, "fs": true, "vb": true, "rs": true, "swift": true,
|
||||
"m": true, "mm": true, "rb": true, "php": true, "pl": true, "pm": true, "lua": true, "r": true,
|
||||
"jl": true, "dart": true, "ex": true, "exs": true, "erl": true, "hs": true, "clj": true,
|
||||
"sh": true, "bash": true, "zsh": true, "fish": true, "ps1": true, "psm1": true, "bat": true,
|
||||
"cmd": true, "groovy": true, "gradle": true, "tf": true, "hcl": true, "proto": true,
|
||||
"graphql": true, "gql": true, "css": true, "scss": true, "sass": true, "less": true,
|
||||
"vue": true, "svelte": true, "dockerfile": true, "makefile": true, "mk": true, "cmake": true,
|
||||
"bib": true, "nfo": true, "diff": true, "patch": true,
|
||||
}
|
||||
|
||||
// officeOnly are formats only LibreOffice reads here: the binary Office
|
||||
// formats before 2007, and others it opens.
|
||||
var officeOnly = []string{
|
||||
"doc", "dot", "xls", "xlt", "xlsb", "ppt", "pps", "pot", "wpd", "wps", "wri",
|
||||
"pages", "numbers", "key", "sxw", "sxc", "sxi", "hwp", "pub", "vsd", "vsdx",
|
||||
}
|
||||
|
||||
// imageExts are read by OCR.
|
||||
var imageExts = []string{"png", "jpg", "jpeg", "tif", "tiff", "bmp", "gif", "webp", "jp2", "pnm"}
|
||||
|
||||
// looksLikeText reports whether bytes are text: no NUL in the first 8 KiB
|
||||
// (UTF-16 has its BOM checked first), and mostly printable.
|
||||
func looksLikeText(b []byte) bool {
|
||||
if hasUTF16BOM(b) {
|
||||
return true
|
||||
}
|
||||
head := b[:min(len(b), 8192)]
|
||||
if bytes.IndexByte(head, 0) >= 0 {
|
||||
return false
|
||||
}
|
||||
if utf8.Valid(head) {
|
||||
return true
|
||||
}
|
||||
// A single-byte code page: allow it when control characters are rare.
|
||||
ctl := 0
|
||||
for _, c := range head {
|
||||
if c < 0x20 && c != '\n' && c != '\r' && c != '\t' && c != '\f' {
|
||||
ctl++
|
||||
}
|
||||
}
|
||||
return ctl*100 < len(head)
|
||||
}
|
||||
|
||||
var (
|
||||
spaceRun = regexp.MustCompile(`[ \t\x{00A0}]+\n`)
|
||||
blankRun = regexp.MustCompile(`\n{3,}`)
|
||||
)
|
||||
|
||||
// Clean normalises extracted text: LF line ends, no trailing spaces, no more
|
||||
// than one blank line in a row, no control characters but tab and newline.
|
||||
func Clean(s string) string {
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||
s = strings.ReplaceAll(s, "\r", "\n")
|
||||
s = strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r == '\n' || r == '\t':
|
||||
return r
|
||||
case r == '\f' || r == '\v':
|
||||
return '\n'
|
||||
case r < 0x20 || r == 0x7f || r == utf8.RuneError || r == '\uFEFF':
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
s = spaceRun.ReplaceAllString(s, "\n")
|
||||
s = blankRun.ReplaceAllString(s, "\n\n")
|
||||
return strings.TrimSpace(s) + "\n"
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package extract
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/text/encoding/charmap"
|
||||
"golang.org/x/text/encoding/unicode"
|
||||
)
|
||||
|
||||
// zipOf builds a zip from name → content, in the order given.
|
||||
func zipOf(t *testing.T, files ...string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
w := zip.NewWriter(&buf)
|
||||
for i := 0; i+1 < len(files); i += 2 {
|
||||
f, err := w.Create(files[i])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.Write([]byte(files[i+1])); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func extractOne(t *testing.T, name string, data []byte) string {
|
||||
t.Helper()
|
||||
parts, err := Bytes(context.Background(), name, data, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
if len(parts) != 1 || parts[0].Err != nil {
|
||||
t.Fatalf("%s: parts %+v", name, parts)
|
||||
}
|
||||
return parts[0].Text
|
||||
}
|
||||
|
||||
func wants(t *testing.T, got string, want ...string) {
|
||||
t.Helper()
|
||||
for _, w := range want {
|
||||
if !strings.Contains(got, w) {
|
||||
t.Errorf("missing %q in:\n%s", w, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextInEveryEncodingBecomesUTF8(t *testing.T) {
|
||||
const s = "Yıllık izin: Şubat'ta ğüşİöç"
|
||||
u16, _ := unicode.UTF16(unicode.LittleEndian, unicode.UseBOM).NewEncoder().Bytes([]byte(s))
|
||||
cp, _ := charmap.Windows1254.NewEncoder().Bytes([]byte(s))
|
||||
for name, b := range map[string][]byte{
|
||||
"utf8": []byte(s),
|
||||
"utf8-bom": append([]byte{0xEF, 0xBB, 0xBF}, s...),
|
||||
"utf16": u16,
|
||||
"cp1254": cp,
|
||||
"crlf": []byte(strings.ReplaceAll(s+"\nikinci", "\n", "\r\n")),
|
||||
} {
|
||||
got := extractOne(t, "a.txt", b)
|
||||
if !strings.HasPrefix(got, s) {
|
||||
t.Errorf("%s: got %q", name, got)
|
||||
}
|
||||
if strings.Contains(got, "\r") {
|
||||
t.Errorf("%s: kept a CR", name)
|
||||
}
|
||||
}
|
||||
// macOS writes names and sometimes text decomposed; the text comes out
|
||||
// composed, so the same word is the same bytes everywhere.
|
||||
if got := extractOne(t, "a.txt", []byte("Şirket")); !strings.HasPrefix(got, "Şirket") {
|
||||
t.Errorf("NFD text came out as %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLKeepsHeadingsAndDropsScripts(t *testing.T) {
|
||||
page := `<html><head><title>T</title><script>var x="gizli";</script></head><body>
|
||||
<nav>menü</nav><h1>İzin Politikası</h1><p>Yıllık <b>izin</b> yirmi gün.</p>
|
||||
<ul><li>Bir</li><li>İki</li></ul><table><tr><td>a</td><td>b</td></tr></table>
|
||||
<p><b>Mer</b>haba</p></body></html>`
|
||||
got := extractOne(t, "a.html", []byte(page))
|
||||
wants(t, got, "# İzin Politikası", "Yıllık izin yirmi gün.", "- Bir", "- İki", "a\tb", "Merhaba")
|
||||
for _, no := range []string{"gizli", "menü"} {
|
||||
if strings.Contains(got, no) {
|
||||
t.Errorf("%q leaked into:\n%s", no, got)
|
||||
}
|
||||
}
|
||||
// Declared charset honoured.
|
||||
l5, _ := charmap.ISO8859_9.NewEncoder().Bytes([]byte(`<meta charset="iso-8859-9"><p>Şirket</p>`))
|
||||
wants(t, extractOne(t, "b.htm", l5), "Şirket")
|
||||
}
|
||||
|
||||
func TestWordDocument(t *testing.T) {
|
||||
doc := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>
|
||||
<w:p><w:pPr><w:pStyle w:val="Balk1"/></w:pPr><w:r><w:t>Yıllık İzin</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:t xml:space="preserve">Çalışanlar </w:t></w:r><w:r><w:t>yirmi gün</w:t></w:r><w:del><w:r><w:delText>otuz</w:delText></w:r></w:del><w:r><w:tab/><w:t>izin alır.</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/></w:numPr></w:pPr><w:r><w:t>Madde bir</w:t></w:r></w:p>
|
||||
<w:tbl><w:tr><w:tc><w:p><w:r><w:t>Hücre1</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>Hücre2</w:t></w:r></w:p></w:tc></w:tr></w:tbl>
|
||||
<w:p><w:pPr><w:pStyle w:val="Heading2"/></w:pPr><w:r><w:t>Masraflar</w:t></w:r></w:p>
|
||||
</w:body></w:document>`
|
||||
got := extractOne(t, "a.docx", zipOf(t, "word/document.xml", doc,
|
||||
"word/footnotes.xml", `<w:footnotes xmlns:w="x"><w:footnote><w:p><w:r><w:t>Dipnot</w:t></w:r></w:p></w:footnote></w:footnotes>`))
|
||||
wants(t, got, "# Yıllık İzin", "Çalışanlar yirmi gün\tizin alır.", "- Madde bir", "Hücre1\tHücre2", "## Masraflar", "Dipnot")
|
||||
if strings.Contains(got, "otuz") {
|
||||
t.Errorf("a tracked deletion is in the text:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkbook(t *testing.T) {
|
||||
got := extractOne(t, "a.xlsx", zipOf(t,
|
||||
"xl/workbook.xml", `<workbook xmlns:r="r"><sheets><sheet name="Bütçe" sheetId="1" r:id="rId1"/><sheet name="Boş" sheetId="2" r:id="rId2"/></sheets></workbook>`,
|
||||
"xl/_rels/workbook.xml.rels", `<Relationships><Relationship Id="rId1" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Target="worksheets/sheet2.xml"/></Relationships>`,
|
||||
"xl/sharedStrings.xml", `<sst><si><t>Kalem</t></si><si><r><t>Tu</t></r><r><t>tar</t></r></si><si><t>Kira</t></si></sst>`,
|
||||
"xl/worksheets/sheet1.xml", `<worksheet><sheetData><row><c t="s"><v>0</v></c><c t="s"><v>1</v></c></row><row><c t="s"><v>2</v></c><c><v>1500.5</v></c><c t="inlineStr"><is><t>not</t></is></c></row></sheetData></worksheet>`,
|
||||
"xl/worksheets/sheet2.xml", `<worksheet><sheetData/></worksheet>`,
|
||||
))
|
||||
wants(t, got, "## Bütçe", "Kalem\tTutar", "Kira\t1500.5\tnot")
|
||||
}
|
||||
|
||||
func TestPresentationInItsOwnOrderWithNotes(t *testing.T) {
|
||||
slide := func(text string) string {
|
||||
return `<p:sld xmlns:a="a" xmlns:p="p"><p:cSld><p:spTree><p:sp><p:txBody><a:p><a:r><a:t>` + text +
|
||||
`</a:t></a:r></a:p><a:p><a:fld type="slidenum"><a:t>9</a:t></a:fld></a:p></p:txBody></p:sp></p:spTree></p:cSld></p:sld>`
|
||||
}
|
||||
got := extractOne(t, "a.pptx", zipOf(t,
|
||||
"ppt/presentation.xml", `<p:presentation xmlns:p="p" xmlns:r="r"><p:sldIdLst><p:sldId id="256" r:id="rId3"/><p:sldId id="257" r:id="rId2"/></p:sldIdLst></p:presentation>`,
|
||||
"ppt/_rels/presentation.xml.rels", `<Relationships><Relationship Id="rId2" Target="slides/slide1.xml"/><Relationship Id="rId3" Target="slides/slide2.xml"/></Relationships>`,
|
||||
"ppt/slides/slide1.xml", slide("İkinci slayt"),
|
||||
"ppt/slides/slide2.xml", slide("Birinci slayt"),
|
||||
"ppt/slides/_rels/slide2.xml.rels", `<Relationships><Relationship Id="rId1" Type="http://x/notesSlide" Target="../notesSlides/notesSlide7.xml"/></Relationships>`,
|
||||
"ppt/notesSlides/notesSlide7.xml", slide("Konuşmacı notu"),
|
||||
))
|
||||
first, second := strings.Index(got, "Birinci slayt"), strings.Index(got, "İkinci slayt")
|
||||
if first < 0 || second < 0 || first > second {
|
||||
t.Errorf("slides out of the presentation's order:\n%s", got)
|
||||
}
|
||||
wants(t, got, "## Slide 1", "Notes:", "Konuşmacı notu")
|
||||
if strings.Contains(got, "9") {
|
||||
t.Errorf("a slide number field is in the text:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenDocument(t *testing.T) {
|
||||
content := `<office:document-content xmlns:office="o" xmlns:text="t" xmlns:table="tb"><office:body><office:text>
|
||||
<text:h text:outline-level="2">Bölüm</text:h><text:p>Bir<text:s text:c="2"/>iki<text:tab/>üç</text:p>
|
||||
<text:list><text:list-item><text:p>madde</text:p></text:list-item></text:list>
|
||||
<table:table table:name="Tablo1"><table:table-row><table:table-cell><text:p>x</text:p></table:table-cell><table:table-cell><text:p>y</text:p></table:table-cell></table:table-row></table:table>
|
||||
</office:text></office:body></office:document-content>`
|
||||
got := extractOne(t, "a.odt", zipOf(t, "mimetype", "application/vnd.oasis.opendocument.text", "content.xml", content))
|
||||
wants(t, got, "## Bölüm", "Bir iki\tüç", "- madde", "## Tablo1", "x\ty")
|
||||
wants(t, extractOne(t, "a.fodt", []byte(content)), "## Bölüm")
|
||||
}
|
||||
|
||||
func TestRichText(t *testing.T) {
|
||||
rtf := `{\rtf1\ansi\ansicpg1254\deff0{\fonttbl{\f0 Calibri;}}{\colortbl;\red0\green0\blue0;}` +
|
||||
`{\*\generator Riched20;}\pard Y\'fdll\'fdk izin\par ` +
|
||||
`\u350?irket \u-3913?\'3f ok\tab sonra\par{\header gizli}\cell b\row }`
|
||||
got := extractOne(t, "a.rtf", []byte(rtf))
|
||||
wants(t, got, "Yıllık izin", "Şirket", "ok\tsonra")
|
||||
for _, no := range []string{"Calibri", "Riched", "gizli"} {
|
||||
if strings.Contains(got, no) {
|
||||
t.Errorf("%q leaked into:\n%s", no, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEbookInSpineOrder(t *testing.T) {
|
||||
got := extractOne(t, "a.epub", zipOf(t,
|
||||
"mimetype", "application/epub+zip",
|
||||
"META-INF/container.xml", `<container><rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles></container>`,
|
||||
"OEBPS/content.opf", `<package><manifest><item id="c1" href="b.xhtml"/><item id="c2" href="a.xhtml"/></manifest><spine><itemref idref="c2"/><itemref idref="c1"/></spine></package>`,
|
||||
"OEBPS/a.xhtml", `<html><body><h1>Önce</h1></body></html>`,
|
||||
"OEBPS/b.xhtml", `<html><body><h1>Sonra</h1></body></html>`,
|
||||
))
|
||||
if a, b := strings.Index(got, "Önce"), strings.Index(got, "Sonra"); a < 0 || b < a {
|
||||
t.Errorf("chapters out of spine order:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotebookAndSubtitles(t *testing.T) {
|
||||
nb := `{"metadata":{"kernelspec":{"language":"python"}},"cells":[{"cell_type":"markdown","source":["# Analiz\n","Veri"]},
|
||||
{"cell_type":"code","source":"print(1)","outputs":[{"text":"çıktı"}]}]}`
|
||||
got := extractOne(t, "a.ipynb", []byte(nb))
|
||||
wants(t, got, "# Analiz", "```python\nprint(1)\n```")
|
||||
if strings.Contains(got, "çıktı") {
|
||||
t.Errorf("an output is in the text:\n%s", got)
|
||||
}
|
||||
srt := "1\n00:00:01,000 --> 00:00:02,000\n<i>Merhaba</i>\n\n2\n00:00:02,000 --> 00:00:03,000\nMerhaba\nDünya\n"
|
||||
if got := extractOne(t, "a.srt", []byte(srt)); got != "Merhaba\nDünya\n" {
|
||||
t.Errorf("subtitles came out as %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFTextLayer(t *testing.T) {
|
||||
b, err := os.ReadFile("testdata/turkish.pdf")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wants(t, extractOne(t, "a.pdf", b), "Yıllık İzin Politikası", "Masraflar finans ekibine iletilir.")
|
||||
// Garbage is a failure, not a panic.
|
||||
if _, err := Bytes(context.Background(), "bad.pdf", []byte("%PDF-1.4 nonsense"), Options{}); err == nil {
|
||||
t.Error("a broken PDF was read")
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchivesAreReadMemberByMember(t *testing.T) {
|
||||
inner := zipOf(t, "deep/c.md", "# Derin")
|
||||
archive := zipOf(t,
|
||||
"docs/a.txt", "Birinci",
|
||||
`docs\b.html`, "<p>İkinci</p>",
|
||||
"__MACOSX/docs/._a.txt", "çöp",
|
||||
"docs/~$kilit.docx", "çöp",
|
||||
"inner.zip", string(inner),
|
||||
"image.bin", "\x00\x01",
|
||||
)
|
||||
parts, err := Bytes(context.Background(), "arsiv.zip", archive, Options{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]string{}
|
||||
for _, p := range parts {
|
||||
if p.Err != nil {
|
||||
t.Errorf("%s: %v", p.Name, p.Err)
|
||||
}
|
||||
got[p.Name] = p.Text
|
||||
}
|
||||
if len(got) != 3 || !strings.Contains(got["docs/a.txt"], "Birinci") || !strings.Contains(got["docs/b.html"], "İkinci") ||
|
||||
!strings.Contains(got["inner.zip!/deep/c.md"], "# Derin") {
|
||||
t.Errorf("archive parts %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailWithAttachments(t *testing.T) {
|
||||
att := zipOf(t, "x.txt", "ekteki metin")
|
||||
enc := func(b []byte) string {
|
||||
s := base64.StdEncoding.EncodeToString(b)
|
||||
var lines []string
|
||||
for len(s) > 76 {
|
||||
lines, s = append(lines, s[:76]), s[76:]
|
||||
}
|
||||
return strings.Join(append(lines, s), "\r\n")
|
||||
}
|
||||
msg := "From: =?UTF-8?B?QXnFn2U=?= <a@example.com>\r\n" +
|
||||
"Subject: =?iso-8859-9?Q?=DEubat_raporu?=\r\n" +
|
||||
"Content-Type: multipart/mixed; boundary=B\r\n\r\n" +
|
||||
"--B\r\nContent-Type: multipart/alternative; boundary=C\r\n\r\n" +
|
||||
"--C\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n" +
|
||||
"Rapor ekte, te=C5=9Fekk=C3=BCrler.\r\n" +
|
||||
"--C\r\nContent-Type: text/html; charset=utf-8\r\n\r\n<p>HTML hali</p>\r\n--C--\r\n" +
|
||||
"--B\r\nContent-Type: application/zip; name=\"ek.zip\"\r\nContent-Transfer-Encoding: base64\r\n" +
|
||||
"Content-Disposition: attachment; filename=\"ek.zip\"\r\n\r\n" + enc(att) + "\r\n--B--\r\n"
|
||||
parts, err := Bytes(context.Background(), "a.eml", []byte(msg), Options{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("parts %+v", parts)
|
||||
}
|
||||
wants(t, parts[0].Text, "# Şubat raporu", "From: Ayşe", "Rapor ekte, teşekkürler.")
|
||||
if strings.Contains(parts[0].Text, "HTML hali") {
|
||||
t.Errorf("the HTML alternative was read beside the plain text:\n%s", parts[0].Text)
|
||||
}
|
||||
if parts[1].Name != "ek.zip!/x.txt" || !strings.Contains(parts[1].Text, "ekteki metin") {
|
||||
t.Errorf("attachment %+v", parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnreadableFilesSayWhy(t *testing.T) {
|
||||
_, err := Bytes(context.Background(), "a.doc", []byte{0xD0, 0xCF, 0x11, 0xE0, 0}, Options{})
|
||||
if !errors.Is(err, ErrUnsupported) || !strings.Contains(err.Error(), "LibreOffice") {
|
||||
t.Errorf("a .doc without LibreOffice gave %v", err)
|
||||
}
|
||||
_, err = Bytes(context.Background(), "a.png", []byte{0x89, 'P', 'N', 'G', 0}, Options{})
|
||||
if !errors.Is(err, ErrUnsupported) || !strings.Contains(err.Error(), "Tesseract") {
|
||||
t.Errorf("an image without Tesseract gave %v", err)
|
||||
}
|
||||
_, err = Bytes(context.Background(), "a.bin", []byte{0, 1, 2, 3}, Options{})
|
||||
if !errors.Is(err, ErrUnsupported) {
|
||||
t.Errorf("binary gave %v", err)
|
||||
}
|
||||
_, err = Bytes(context.Background(), "a.txt", []byte(" \n"), Options{})
|
||||
if !errors.Is(err, ErrNoText) {
|
||||
t.Errorf("an empty file gave %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpersWhenInstalled(t *testing.T) {
|
||||
h := FindHelpers()
|
||||
ctx := context.Background()
|
||||
if h.Tesseract != "" && h.PdfToPPM != "" {
|
||||
// The text-layer PDF read by OCR instead: what a scanned one gets.
|
||||
b, _ := os.ReadFile("testdata/turkish.pdf")
|
||||
got, err := pdfWithHelpers(ctx, "a.pdf", b, Options{Helpers: Helpers{Tesseract: h.Tesseract, PdfToPPM: h.PdfToPPM}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wants(t, got, "Masraflar")
|
||||
} else {
|
||||
t.Log("no Tesseract with pdftoppm here; OCR not tried")
|
||||
}
|
||||
if h.Office == "" {
|
||||
t.Log("no LibreOffice here; legacy Office formats not tried")
|
||||
return
|
||||
}
|
||||
// A Word document converted to the legacy format and back would need a
|
||||
// fixture made by Word; an ODT through the Office path proves the
|
||||
// conversion runs and its result is read.
|
||||
content := `<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"><office:body><office:text><text:p>LibreOffice yolu</text:p></office:text></office:body></office:document-content>`
|
||||
got, err := h.officeToText(ctx, "a.fodt", []byte(`<?xml version="1.0"?>`+content), Options{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wants(t, got, "LibreOffice yolu")
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package extract
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Helpers are programs on this machine that read what Go here cannot. Any
|
||||
// may be "", and the formats that need it are then skipped with a reason.
|
||||
type Helpers struct {
|
||||
// Office is LibreOffice's soffice: the binary Office formats and others.
|
||||
Office string
|
||||
// Tesseract reads images and scanned pages.
|
||||
Tesseract string
|
||||
// PdfToText and PdfToPPM are Poppler's: a second PDF reader, and pages
|
||||
// rendered for Tesseract.
|
||||
PdfToText string
|
||||
PdfToPPM string
|
||||
}
|
||||
|
||||
// helperTimeout bounds one helper run; a conversion that hangs is a file
|
||||
// skipped, not a sync stopped.
|
||||
const helperTimeout = 3 * time.Minute
|
||||
|
||||
// FindHelpers looks for the helpers on PATH and where their installers put
|
||||
// them on each system.
|
||||
func FindHelpers() Helpers {
|
||||
return Helpers{
|
||||
Office: find("soffice", officePlaces()...),
|
||||
Tesseract: find("tesseract", tesseractPlaces()...),
|
||||
PdfToText: find("pdftotext"),
|
||||
PdfToPPM: find("pdftoppm"),
|
||||
}
|
||||
}
|
||||
|
||||
// Describe says which helpers were found, and what each missing one would
|
||||
// read.
|
||||
func (h Helpers) Describe() []string {
|
||||
line := func(have, name, reads string) string {
|
||||
if have != "" {
|
||||
return fmt.Sprintf("%-11s %s", name, have)
|
||||
}
|
||||
return fmt.Sprintf("%-11s not found — %s are skipped", name, reads)
|
||||
}
|
||||
return []string{
|
||||
line(h.Office, "LibreOffice", "Word 97–2003, Excel 97–2003, PowerPoint 97–2003, Pages, Numbers, Keynote and similar files"),
|
||||
line(h.Tesseract, "Tesseract", "images and scanned PDFs"),
|
||||
line(h.PdfToText, "pdftotext", "PDFs the built-in reader cannot parse"),
|
||||
line(h.PdfToPPM, "pdftoppm", "scanned PDFs (Tesseract needs their pages as images)"),
|
||||
}
|
||||
}
|
||||
|
||||
func find(name string, places ...string) string {
|
||||
exe := name
|
||||
if runtime.GOOS == "windows" {
|
||||
exe += ".exe"
|
||||
}
|
||||
if p, err := exec.LookPath(exe); err == nil {
|
||||
return p
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
if p, err := exec.LookPath(name); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
for _, p := range places {
|
||||
if matches, _ := filepath.Glob(p); len(matches) > 0 {
|
||||
sort.Strings(matches)
|
||||
return matches[len(matches)-1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func officePlaces() []string {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
return []string{
|
||||
`C:\Program Files\LibreOffice*\program\soffice.exe`,
|
||||
`C:\Program Files (x86)\LibreOffice*\program\soffice.exe`,
|
||||
}
|
||||
case "darwin":
|
||||
return []string{"/Applications/LibreOffice.app/Contents/MacOS/soffice"}
|
||||
}
|
||||
return []string{"/usr/bin/libreoffice", "/usr/lib/libreoffice/program/soffice", "/opt/libreoffice*/program/soffice",
|
||||
"/snap/bin/libreoffice"}
|
||||
}
|
||||
|
||||
func tesseractPlaces() []string {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
return []string{`C:\Program Files\Tesseract-OCR\tesseract.exe`, `C:\Program Files (x86)\Tesseract-OCR\tesseract.exe`}
|
||||
case "darwin":
|
||||
return []string{"/opt/homebrew/bin/tesseract", "/usr/local/bin/tesseract"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// workDir is a temporary directory for one helper run, removed after.
|
||||
func workDir() (string, func(), error) {
|
||||
d, err := os.MkdirTemp("", "llmgw-kb-")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return d, func() { _ = os.RemoveAll(d) }, nil
|
||||
}
|
||||
|
||||
// writeInput puts a document where a helper can read it, under a safe name
|
||||
// that keeps its extension.
|
||||
func writeInput(dir, name string, data []byte) (string, error) {
|
||||
p := filepath.Join(dir, "input."+Ext(name))
|
||||
return p, os.WriteFile(p, data, 0o600)
|
||||
}
|
||||
|
||||
func run(ctx context.Context, dir string, bin string, args ...string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, helperTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, bin, args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.Output()
|
||||
if ctx.Err() != nil {
|
||||
return nil, fmt.Errorf("%s took over %s", filepath.Base(bin), helperTimeout)
|
||||
}
|
||||
if err != nil {
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) && len(ee.Stderr) > 0 {
|
||||
return nil, fmt.Errorf("%s: %v: %s", filepath.Base(bin), err, lastLine(ee.Stderr))
|
||||
}
|
||||
return nil, fmt.Errorf("%s: %w", filepath.Base(bin), err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func lastLine(b []byte) string {
|
||||
lines := strings.Split(strings.TrimSpace(string(b)), "\n")
|
||||
return strings.TrimSpace(lines[len(lines)-1])
|
||||
}
|
||||
|
||||
// fileURL is a path as LibreOffice takes its profile directory: a file URL,
|
||||
// with a drive letter on Windows.
|
||||
func fileURL(p string) string {
|
||||
p = filepath.ToSlash(p)
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
p = "/" + p
|
||||
}
|
||||
return "file://" + p
|
||||
}
|
||||
|
||||
var spreadsheets = map[string]bool{"xls": true, "xlt": true, "xlsb": true, "numbers": true, "sxc": true}
|
||||
var presentations = map[string]bool{"ppt": true, "pps": true, "pot": true, "key": true, "sxi": true}
|
||||
|
||||
func viaOffice(ctx context.Context, name string, data []byte, o Options) (string, error) {
|
||||
if o.Helpers.Office == "" {
|
||||
return "", fmt.Errorf("%w: a .%s file needs LibreOffice, which is not installed here", ErrUnsupported, Ext(name))
|
||||
}
|
||||
return o.Helpers.officeToText(ctx, name, data, o)
|
||||
}
|
||||
|
||||
// officeToText has LibreOffice convert a document to the matching Office
|
||||
// Open XML format, then reads that: headings, sheets and slides survive,
|
||||
// which a conversion to plain text would lose.
|
||||
func (h Helpers) officeToText(ctx context.Context, name string, data []byte, _ Options) (string, error) {
|
||||
dir, done, err := workDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer done()
|
||||
in, err := writeInput(dir, name, data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ext := Ext(name)
|
||||
to, read := "docx", docxText
|
||||
switch {
|
||||
case spreadsheets[ext]:
|
||||
to, read = "xlsx", xlsxText
|
||||
case presentations[ext]:
|
||||
to, read = "pptx", pptxText
|
||||
}
|
||||
// A profile of its own: two conversions at once with the user's
|
||||
// profile wait on each other's lock, or fail.
|
||||
profile := fileURL(filepath.Join(dir, "profile"))
|
||||
if _, err := run(ctx, dir, h.Office, "-env:UserInstallation="+profile, "--headless", "--norestore",
|
||||
"--convert-to", to, "--outdir", dir, in); err != nil {
|
||||
return "", err
|
||||
}
|
||||
converted, err := os.ReadFile(filepath.Join(dir, "input."+to))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("LibreOffice did not convert it: %w", err)
|
||||
}
|
||||
return read(converted)
|
||||
}
|
||||
|
||||
func viaOCR(ctx context.Context, name string, data []byte, o Options) (string, error) {
|
||||
if o.Helpers.Tesseract == "" {
|
||||
return "", fmt.Errorf("%w: an image needs Tesseract to be read, which is not installed here", ErrUnsupported)
|
||||
}
|
||||
dir, done, err := workDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer done()
|
||||
in, err := writeInput(dir, name, data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ocr(ctx, dir, o, in)
|
||||
}
|
||||
|
||||
func ocr(ctx context.Context, dir string, o Options, file string) (string, error) {
|
||||
langs := o.OCRLanguages
|
||||
if langs == "" {
|
||||
langs = "tur+eng"
|
||||
}
|
||||
out, err := run(ctx, dir, o.Helpers.Tesseract, file, "stdout", "-l", langs)
|
||||
if err != nil && langs != "eng" {
|
||||
// A Tesseract without the Turkish data still reads English.
|
||||
out, err = run(ctx, dir, o.Helpers.Tesseract, file, "stdout", "-l", "eng")
|
||||
}
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
// maxOCRPages bounds how many pages of one scanned PDF are read.
|
||||
const maxOCRPages = 500
|
||||
|
||||
// pdfWithHelpers reads a PDF the built-in reader could not: with pdftotext,
|
||||
// and when that finds no text either, page by page through Tesseract.
|
||||
func pdfWithHelpers(ctx context.Context, name string, data []byte, o Options) (string, error) {
|
||||
h := o.Helpers
|
||||
if h.PdfToText == "" && (h.Tesseract == "" || h.PdfToPPM == "") {
|
||||
return "", fmt.Errorf("%w: no text layer the built-in reader can use; Poppler (pdftotext) or "+
|
||||
"Tesseract with pdftoppm would read it", ErrNoText)
|
||||
}
|
||||
dir, done, err := workDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer done()
|
||||
in, err := writeInput(dir, name, data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if h.PdfToText != "" {
|
||||
if out, err := run(ctx, dir, h.PdfToText, "-enc", "UTF-8", in, "-"); err == nil && strings.TrimSpace(string(out)) != "" {
|
||||
return string(out), nil
|
||||
}
|
||||
}
|
||||
if h.Tesseract == "" || h.PdfToPPM == "" {
|
||||
return "", fmt.Errorf("%w: a scanned PDF needs Tesseract and pdftoppm", ErrNoText)
|
||||
}
|
||||
if _, err := run(ctx, dir, h.PdfToPPM, "-r", "300", "-gray", "-png", "-l", fmt.Sprint(maxOCRPages), in,
|
||||
filepath.Join(dir, "page")); err != nil {
|
||||
return "", err
|
||||
}
|
||||
pages, _ := filepath.Glob(filepath.Join(dir, "page-*.png"))
|
||||
// pdftoppm pads page numbers to the width of the last one, so the
|
||||
// names sort in page order.
|
||||
sort.Strings(pages)
|
||||
var all strings.Builder
|
||||
for _, p := range pages {
|
||||
t, err := ocr(ctx, dir, o, p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
all.WriteString(t)
|
||||
all.WriteString("\n\n")
|
||||
}
|
||||
return all.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package extract
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/mail"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
"golang.org/x/net/html/charset"
|
||||
)
|
||||
|
||||
// out builds Markdown-shaped text: paragraphs, headings, list items, table
|
||||
// rows, with whitespace collapsed inside them.
|
||||
type out struct {
|
||||
b strings.Builder
|
||||
// line is the text of the line being built, whitespace collapsed.
|
||||
line strings.Builder
|
||||
prefix string
|
||||
}
|
||||
|
||||
// text adds running text; runs of whitespace become one space, and none is
|
||||
// added where the source had none ("<b>Mer</b>haba" is one word).
|
||||
func (o *out) text(s string) {
|
||||
if s == "" {
|
||||
return
|
||||
}
|
||||
r := []rune(s)
|
||||
lead, trail := isSpace(r[0]), isSpace(r[len(r)-1])
|
||||
for i, f := range strings.FieldsFunc(s, isSpace) {
|
||||
if i > 0 || lead {
|
||||
o.space()
|
||||
}
|
||||
o.line.WriteString(f)
|
||||
}
|
||||
if trail {
|
||||
o.space()
|
||||
}
|
||||
}
|
||||
|
||||
// space adds one space unless the line is empty or already ends in one.
|
||||
func (o *out) space() {
|
||||
l := o.line.String()
|
||||
if l != "" && !strings.HasSuffix(l, " ") && !strings.HasSuffix(l, "\t") {
|
||||
o.line.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
|
||||
// raw adds text as it is, for preformatted blocks.
|
||||
func (o *out) raw(s string) { o.line.WriteString(s) }
|
||||
|
||||
// tab separates cells.
|
||||
func (o *out) tab() {
|
||||
o.trimLine()
|
||||
o.line.WriteString("\t")
|
||||
}
|
||||
|
||||
func (o *out) trimLine() {
|
||||
s := strings.TrimRight(o.line.String(), " ")
|
||||
o.line.Reset()
|
||||
o.line.WriteString(s)
|
||||
}
|
||||
|
||||
// newline ends the line being built, if it has anything.
|
||||
func (o *out) newline() {
|
||||
s := strings.TrimSpace(o.line.String())
|
||||
o.line.Reset()
|
||||
if s == "" {
|
||||
return
|
||||
}
|
||||
o.b.WriteString(o.prefix)
|
||||
o.b.WriteString(s)
|
||||
o.b.WriteByte('\n')
|
||||
o.prefix = ""
|
||||
}
|
||||
|
||||
// para ends a paragraph: the line, and a blank line after it.
|
||||
func (o *out) para() {
|
||||
o.newline()
|
||||
if o.b.Len() > 0 && !strings.HasSuffix(o.b.String(), "\n\n") {
|
||||
o.b.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
|
||||
// heading starts a heading of a level, 1 to 6.
|
||||
func (o *out) heading(level int) {
|
||||
o.para()
|
||||
level = max(1, min(level, 6))
|
||||
o.prefix = strings.Repeat("#", level) + " "
|
||||
}
|
||||
|
||||
// item starts a list item.
|
||||
func (o *out) item() {
|
||||
o.newline()
|
||||
o.prefix = "- "
|
||||
}
|
||||
|
||||
func (o *out) String() string {
|
||||
o.newline()
|
||||
return o.b.String()
|
||||
}
|
||||
|
||||
func isSpace(r rune) bool {
|
||||
return r == ' ' || r == '\t' || r == '\n' || r == '\r' || r == '\f' || r == ' '
|
||||
}
|
||||
|
||||
// htmlSkip are elements whose content is not text a reader sees.
|
||||
var htmlSkip = map[string]bool{
|
||||
"script": true, "style": true, "noscript": true, "template": true, "svg": true,
|
||||
"head": true, "nav": true, "iframe": true, "object": true, "canvas": true, "button": true, "select": true,
|
||||
}
|
||||
|
||||
// htmlBlock are elements that end a paragraph.
|
||||
var htmlBlock = map[string]bool{
|
||||
"p": true, "div": true, "section": true, "article": true, "main": true, "header": true,
|
||||
"footer": true, "aside": true, "blockquote": true, "pre": true, "table": true, "ul": true,
|
||||
"ol": true, "dl": true, "figure": true, "figcaption": true, "address": true, "hr": true,
|
||||
"form": true, "fieldset": true, "details": true, "summary": true, "body": true, "dt": true, "dd": true,
|
||||
"caption": true,
|
||||
}
|
||||
|
||||
// htmlText reads an HTML page, in the charset it declares.
|
||||
func htmlText(b []byte) (string, error) {
|
||||
r, err := charset.NewReader(bytes.NewReader(b), "text/html")
|
||||
if err != nil {
|
||||
r = bytes.NewReader(b)
|
||||
}
|
||||
return htmlFrom(r)
|
||||
}
|
||||
|
||||
// htmlFrom reads HTML already decoded to UTF-8.
|
||||
func htmlFrom(r io.Reader) (string, error) {
|
||||
z := html.NewTokenizer(r)
|
||||
var o out
|
||||
skip, pre := 0, 0
|
||||
for {
|
||||
tt := z.Next()
|
||||
switch tt {
|
||||
case html.ErrorToken:
|
||||
if z.Err() == io.EOF {
|
||||
return o.String(), nil
|
||||
}
|
||||
return o.String(), z.Err()
|
||||
case html.TextToken:
|
||||
if skip > 0 {
|
||||
continue
|
||||
}
|
||||
if pre > 0 {
|
||||
o.raw(string(z.Text()))
|
||||
} else {
|
||||
o.text(string(z.Text()))
|
||||
}
|
||||
case html.StartTagToken, html.SelfClosingTagToken, html.EndTagToken:
|
||||
name, _ := z.TagName()
|
||||
tag := string(name)
|
||||
end := tt == html.EndTagToken
|
||||
if htmlSkip[tag] {
|
||||
if tt == html.StartTagToken {
|
||||
skip++
|
||||
} else if end && skip > 0 {
|
||||
skip--
|
||||
}
|
||||
continue
|
||||
}
|
||||
if skip > 0 {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case len(tag) == 2 && tag[0] == 'h' && tag[1] >= '1' && tag[1] <= '6':
|
||||
if end {
|
||||
o.para()
|
||||
} else {
|
||||
o.heading(int(tag[1] - '0'))
|
||||
}
|
||||
case tag == "title" && !end:
|
||||
// Inside head, which is skipped; a title outside it is a
|
||||
// heading.
|
||||
o.heading(1)
|
||||
case tag == "li":
|
||||
if !end {
|
||||
o.item()
|
||||
} else {
|
||||
o.newline()
|
||||
}
|
||||
case tag == "br":
|
||||
o.newline()
|
||||
case tag == "tr":
|
||||
o.newline()
|
||||
case tag == "td" || tag == "th":
|
||||
if end {
|
||||
o.tab()
|
||||
}
|
||||
case tag == "pre":
|
||||
if end {
|
||||
pre--
|
||||
} else {
|
||||
pre++
|
||||
}
|
||||
o.para()
|
||||
case htmlBlock[tag]:
|
||||
o.para()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mhtmlText reads a web page saved as one file (MHTML): a MIME message whose
|
||||
// HTML part is the page.
|
||||
func mhtmlText(b []byte) (string, error) {
|
||||
m, err := mail.ReadMessage(bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var best string
|
||||
err = walkMIME(m.Header.Get("Content-Type"), m.Header.Get("Content-Transfer-Encoding"), m.Body, 0,
|
||||
func(ctype, _ string, params map[string]string, body []byte) {
|
||||
if best != "" {
|
||||
return
|
||||
}
|
||||
switch ctype {
|
||||
case "text/html":
|
||||
best, _ = htmlFrom(strings.NewReader(decodeCharset(body, params["charset"])))
|
||||
}
|
||||
})
|
||||
return best, err
|
||||
}
|
||||
|
||||
// walkMIME calls leaf for every non-multipart part of a MIME body, decoded
|
||||
// from its transfer encoding.
|
||||
func walkMIME(contentType, cte string, body io.Reader, depth int,
|
||||
leaf func(ctype, filename string, params map[string]string, body []byte),
|
||||
) error {
|
||||
if depth > 8 {
|
||||
return nil
|
||||
}
|
||||
ctype, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
ctype, params = "text/plain", map[string]string{}
|
||||
}
|
||||
if strings.HasPrefix(ctype, "multipart/") {
|
||||
mr := multipart.NewReader(body, params["boundary"])
|
||||
for {
|
||||
p, err := mr.NextRawPart()
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := walkMIME(p.Header.Get("Content-Type"), p.Header.Get("Content-Transfer-Encoding"), p, depth+1,
|
||||
func(ct, fn string, pr map[string]string, b []byte) {
|
||||
if fn == "" {
|
||||
fn = filenameOf(p.Header.Get("Content-Disposition"))
|
||||
}
|
||||
leaf(ct, fn, pr, b)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
data, err := io.ReadAll(transferDecoder(cte, body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
leaf(ctype, params["name"], params, data)
|
||||
return nil
|
||||
}
|
||||
|
||||
func filenameOf(disposition string) string {
|
||||
_, p, err := mime.ParseMediaType(disposition)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return p["filename"]
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package extract
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/ledongthuc/pdf"
|
||||
)
|
||||
|
||||
// epubText reads an e-book: its chapters in reading order (the spine).
|
||||
func epubText(b []byte) (string, error) {
|
||||
zr, err := openZip(b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
container, _ := member(zr, "META-INF/container.xml")
|
||||
if container == nil {
|
||||
return "", errors.New("not an EPUB: no META-INF/container.xml")
|
||||
}
|
||||
var opf string
|
||||
_ = walkXML(container, func(e xmlEvent) {
|
||||
if e.start && e.name == "rootfile" && opf == "" {
|
||||
opf = e.get("full-path")
|
||||
}
|
||||
})
|
||||
pkg, _ := member(zr, opf)
|
||||
if pkg == nil {
|
||||
return "", fmt.Errorf("not an EPUB: no package document %q", opf)
|
||||
}
|
||||
dir := path.Dir(opf)
|
||||
items := map[string]string{}
|
||||
var spine []string
|
||||
_ = walkXML(pkg, func(e xmlEvent) {
|
||||
switch {
|
||||
case e.start && e.name == "item":
|
||||
items[e.get("id")] = path.Clean(path.Join(dir, e.get("href")))
|
||||
case e.start && e.name == "itemref":
|
||||
spine = append(spine, e.get("idref"))
|
||||
}
|
||||
})
|
||||
var all strings.Builder
|
||||
for _, id := range spine {
|
||||
page, _ := member(zr, items[id])
|
||||
if page == nil {
|
||||
continue
|
||||
}
|
||||
t, err := htmlText(page)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
all.WriteString(t)
|
||||
all.WriteString("\n\n")
|
||||
}
|
||||
return all.String(), nil
|
||||
}
|
||||
|
||||
// notebookText reads a Jupyter notebook: Markdown cells as they are, code
|
||||
// cells fenced; outputs left out, since they are mostly numbers and images.
|
||||
func notebookText(b []byte) (string, error) {
|
||||
var nb struct {
|
||||
Cells []struct {
|
||||
CellType string `json:"cell_type"`
|
||||
Source json.RawMessage `json:"source"`
|
||||
} `json:"cells"`
|
||||
Metadata struct {
|
||||
Kernel struct {
|
||||
Language string `json:"language"`
|
||||
} `json:"kernelspec"`
|
||||
} `json:"metadata"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &nb); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var out strings.Builder
|
||||
for _, c := range nb.Cells {
|
||||
src := jsonText(c.Source)
|
||||
if strings.TrimSpace(src) == "" {
|
||||
continue
|
||||
}
|
||||
switch c.CellType {
|
||||
case "code":
|
||||
out.WriteString("```" + nb.Metadata.Kernel.Language + "\n" + strings.TrimRight(src, "\n") + "\n```\n\n")
|
||||
default:
|
||||
out.WriteString(strings.TrimRight(src, "\n") + "\n\n")
|
||||
}
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
|
||||
// jsonText is a notebook source: a string, or a list of lines.
|
||||
func jsonText(raw json.RawMessage) string {
|
||||
var s string
|
||||
if json.Unmarshal(raw, &s) == nil {
|
||||
return s
|
||||
}
|
||||
var lines []string
|
||||
if json.Unmarshal(raw, &lines) == nil {
|
||||
return strings.Join(lines, "")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var (
|
||||
subtitleTiming = regexp.MustCompile(`^\d{1,2}:\d{2}(:\d{2})?[.,]\d{3}\s+-->`)
|
||||
subtitleIndex = regexp.MustCompile(`^\d+$`)
|
||||
subtitleTag = regexp.MustCompile(`<[^>]+>`)
|
||||
)
|
||||
|
||||
// subtitleText reads SRT and WebVTT: the spoken lines, without numbers,
|
||||
// timings and styling.
|
||||
func subtitleText(b []byte) (string, error) {
|
||||
var out strings.Builder
|
||||
last := ""
|
||||
for _, line := range strings.Split(decodeText(b), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
switch {
|
||||
case line == "", line == "WEBVTT", strings.HasPrefix(line, "NOTE"),
|
||||
subtitleIndex.MatchString(line), subtitleTiming.MatchString(line):
|
||||
continue
|
||||
}
|
||||
line = strings.TrimSpace(subtitleTag.ReplaceAllString(line, ""))
|
||||
// Captions repeat a line across cues as it scrolls.
|
||||
if line == "" || line == last {
|
||||
continue
|
||||
}
|
||||
last = line
|
||||
out.WriteString(line + "\n")
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
|
||||
// pdfText reads a PDF's text layer, page by page, rows in reading order.
|
||||
// A scanned PDF has none; pdfWithHelpers is then tried.
|
||||
func pdfText(b []byte) (text string, err error) {
|
||||
// The reader panics on some malformed files rather than failing; a bad
|
||||
// PDF is one skipped file, not a stopped sync.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
text, err = "", fmt.Errorf("the PDF could not be parsed: %v", r)
|
||||
}
|
||||
}()
|
||||
r, err := pdf.NewReader(bytes.NewReader(b), int64(len(b)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var o out
|
||||
for i := 1; i <= r.NumPage(); i++ {
|
||||
p := r.Page(i)
|
||||
if p.V.IsNull() {
|
||||
continue
|
||||
}
|
||||
rows, err := p.GetTextByRow()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, row := range rows {
|
||||
var line strings.Builder
|
||||
for _, w := range row.Content {
|
||||
line.WriteString(w.S)
|
||||
}
|
||||
o.text(line.String())
|
||||
o.newline()
|
||||
}
|
||||
o.para()
|
||||
}
|
||||
return o.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package extract
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// odfText reads an OpenDocument file (LibreOffice's ODT, ODS, ODP): its
|
||||
// content.xml.
|
||||
func odfText(b []byte) (string, error) {
|
||||
zr, err := openZip(b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
content, err := member(zr, "content.xml")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if content == nil {
|
||||
return "", errors.New("not an OpenDocument file: no content.xml")
|
||||
}
|
||||
return odfXML(content)
|
||||
}
|
||||
|
||||
// flatODFText reads the single-file XML form (.fodt, .fods, .fodp).
|
||||
func flatODFText(b []byte) (string, error) { return odfXML(b) }
|
||||
|
||||
// odfXML reads OpenDocument's text model, which all three kinds share:
|
||||
// text:h with an outline level is a heading, text:p a paragraph, list items,
|
||||
// table rows and cells, slides (draw:page) and sheets (table:table) named as
|
||||
// headings.
|
||||
func odfXML(b []byte) (string, error) {
|
||||
var o out
|
||||
skip, slide := 0, 0
|
||||
// Paragraphs inside a cell or a list item do not end the row or the list.
|
||||
inCell, inItem := 0, 0
|
||||
err := walkXML(b, func(e xmlEvent) {
|
||||
switch {
|
||||
case e.start:
|
||||
switch e.name {
|
||||
case "tracked-changes", "notes-configuration", "annotation", "frame-name":
|
||||
skip++
|
||||
case "h":
|
||||
lvl, _ := strconv.Atoi(e.get("outline-level"))
|
||||
o.heading(max(lvl, 1))
|
||||
case "list-item":
|
||||
inItem++
|
||||
o.item()
|
||||
case "table-cell":
|
||||
inCell++
|
||||
case "tab":
|
||||
o.raw("\t")
|
||||
case "s":
|
||||
n, _ := strconv.Atoi(e.get("c"))
|
||||
for range max(n, 1) {
|
||||
o.raw(" ")
|
||||
}
|
||||
case "line-break":
|
||||
o.newline()
|
||||
case "page":
|
||||
slide++
|
||||
o.heading(2)
|
||||
name := e.get("name")
|
||||
if name == "" || isDefaultPageName(name) {
|
||||
name = fmt.Sprintf("Slide %d", slide)
|
||||
}
|
||||
o.text(name)
|
||||
o.para()
|
||||
case "table":
|
||||
if n := e.get("name"); n != "" {
|
||||
o.heading(2)
|
||||
o.text(n)
|
||||
o.para()
|
||||
}
|
||||
}
|
||||
case e.end:
|
||||
switch e.name {
|
||||
case "tracked-changes", "notes-configuration", "annotation", "frame-name":
|
||||
skip--
|
||||
case "h", "p":
|
||||
switch {
|
||||
case inCell > 0:
|
||||
o.text(" ")
|
||||
case inItem > 0:
|
||||
o.newline()
|
||||
default:
|
||||
o.para()
|
||||
}
|
||||
case "list-item":
|
||||
inItem--
|
||||
o.newline()
|
||||
case "table-cell":
|
||||
inCell--
|
||||
o.tab()
|
||||
case "table-row":
|
||||
o.newline()
|
||||
case "table", "page":
|
||||
o.para()
|
||||
}
|
||||
default:
|
||||
if skip == 0 {
|
||||
o.raw(e.text)
|
||||
}
|
||||
}
|
||||
})
|
||||
return o.String(), err
|
||||
}
|
||||
|
||||
// isDefaultPageName is LibreOffice's own name for an unnamed slide, "page1".
|
||||
func isDefaultPageName(s string) bool {
|
||||
if len(s) < 5 || s[:4] != "page" {
|
||||
return false
|
||||
}
|
||||
_, err := strconv.Atoi(s[4:])
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
package extract
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// maxMember is the most one member of an Office file or EPUB is inflated
|
||||
// to: a zip bomb stops here rather than at the machine's memory.
|
||||
const maxMember = 512 << 20
|
||||
|
||||
func openZip(b []byte) (*zip.Reader, error) {
|
||||
return zip.NewReader(bytes.NewReader(b), int64(len(b)))
|
||||
}
|
||||
|
||||
// member reads one file inside a zip, by its exact name; nil when absent.
|
||||
func member(zr *zip.Reader, name string) ([]byte, error) {
|
||||
for _, f := range zr.File {
|
||||
if f.Name == name {
|
||||
return readMember(f)
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func readMember(f *zip.File) ([]byte, error) {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
b, err := io.ReadAll(io.LimitReader(rc, maxMember+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(b) > maxMember {
|
||||
return nil, fmt.Errorf("%s inflates past %d MiB", f.Name, maxMember>>20)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// relID is an element's r:id, the namespaced id that names a relationship —
|
||||
// not its own plain id, which sldId has as well.
|
||||
func (e xmlEvent) relID() string {
|
||||
for _, a := range e.attr {
|
||||
if a.Name.Local == "id" && a.Name.Space != "" {
|
||||
return a.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// xmlEvent is one step of walking an XML document: a start, an end, or
|
||||
// character data, with the element's local name and attributes.
|
||||
type xmlEvent struct {
|
||||
start, end bool
|
||||
name string
|
||||
attr []xml.Attr
|
||||
text string
|
||||
}
|
||||
|
||||
func (e xmlEvent) get(local string) string {
|
||||
for _, a := range e.attr {
|
||||
if a.Name.Local == local {
|
||||
return a.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// walkXML calls f for each element start and end and each run of character
|
||||
// data. Namespaces are dropped: OOXML and ODF are read by local names.
|
||||
func walkXML(b []byte, f func(xmlEvent)) error {
|
||||
d := xml.NewDecoder(bytes.NewReader(b))
|
||||
d.Strict = false
|
||||
d.CharsetReader = func(cs string, in io.Reader) (io.Reader, error) {
|
||||
all, err := io.ReadAll(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return strings.NewReader(decodeCharset(all, cs)), nil
|
||||
}
|
||||
for {
|
||||
tok, err := d.Token()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
f(xmlEvent{start: true, name: t.Name.Local, attr: t.Attr})
|
||||
case xml.EndElement:
|
||||
f(xmlEvent{end: true, name: t.Name.Local})
|
||||
case xml.CharData:
|
||||
f(xmlEvent{text: string(t)})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// headingLevel reads a heading level out of a paragraph style: Word's
|
||||
// "Heading1", "Heading 2", "heading3", its Turkish "Balk1" (the id Word gives
|
||||
// "Başlık 1"), "Title" and "Subtitle".
|
||||
func headingLevel(style string) int {
|
||||
s := strings.ToLower(strings.ReplaceAll(style, " ", ""))
|
||||
switch s {
|
||||
case "title", "konubaşlığı", "konubal":
|
||||
return 1
|
||||
case "subtitle", "altkonubaşlığı", "altkonubal":
|
||||
return 2
|
||||
}
|
||||
for _, p := range []string{"heading", "balk", "başlık", "berschrift", "titre", "título", "titolo", "kop"} {
|
||||
if rest, ok := strings.CutPrefix(s, p); ok {
|
||||
if n, err := strconv.Atoi(rest); err == nil && n >= 1 && n <= 9 {
|
||||
return min(n, 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// docxText reads a Word document: the body, then footnotes and endnotes.
|
||||
// Paragraph styles give headings; tables are rows of tab-separated cells.
|
||||
func docxText(b []byte) (string, error) {
|
||||
zr, err := openZip(b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body, err := member(zr, "word/document.xml")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if body == nil {
|
||||
return "", errors.New("not a Word document: no word/document.xml")
|
||||
}
|
||||
var o out
|
||||
if err := wordML(body, &o); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, extra := range []string{"word/footnotes.xml", "word/endnotes.xml"} {
|
||||
if x, _ := member(zr, extra); x != nil {
|
||||
_ = wordML(x, &o)
|
||||
}
|
||||
}
|
||||
return o.String(), nil
|
||||
}
|
||||
|
||||
func wordML(b []byte, o *out) error {
|
||||
inText, inDel, inCell := false, 0, 0
|
||||
level, list := 0, false
|
||||
return walkXML(b, func(e xmlEvent) {
|
||||
switch {
|
||||
case e.start:
|
||||
switch e.name {
|
||||
case "p":
|
||||
level, list = 0, false
|
||||
case "pStyle":
|
||||
level = headingLevel(e.get("val"))
|
||||
if strings.Contains(strings.ToLower(e.get("val")), "list") {
|
||||
list = true
|
||||
}
|
||||
case "numPr":
|
||||
list = true
|
||||
case "t":
|
||||
inText = true
|
||||
if level > 0 && inCell == 0 && o.line.Len() == 0 && o.prefix == "" {
|
||||
o.heading(level)
|
||||
} else if list && inCell == 0 && o.line.Len() == 0 && o.prefix == "" {
|
||||
o.item()
|
||||
}
|
||||
case "delText":
|
||||
// Tracked deletions are not the document's text.
|
||||
inDel++
|
||||
case "tc":
|
||||
inCell++
|
||||
case "tab":
|
||||
o.raw("\t")
|
||||
case "br", "cr":
|
||||
o.newline()
|
||||
}
|
||||
case e.end:
|
||||
switch e.name {
|
||||
case "t":
|
||||
inText = false
|
||||
case "delText":
|
||||
inDel--
|
||||
case "p":
|
||||
switch {
|
||||
case inCell > 0:
|
||||
// A cell's paragraphs stay in its row.
|
||||
o.text(" ")
|
||||
case list:
|
||||
o.newline()
|
||||
default:
|
||||
o.para()
|
||||
}
|
||||
case "tc":
|
||||
inCell--
|
||||
o.tab()
|
||||
case "tr":
|
||||
o.newline()
|
||||
case "tbl":
|
||||
o.para()
|
||||
}
|
||||
default:
|
||||
if inText && inDel == 0 {
|
||||
o.raw(e.text)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// xlsxText reads a workbook: each sheet under its name as a heading, each
|
||||
// row a line of tab-separated cells, shared strings resolved.
|
||||
func xlsxText(b []byte) (string, error) {
|
||||
zr, err := openZip(b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var shared []string
|
||||
if ss, _ := member(zr, "xl/sharedStrings.xml"); ss != nil {
|
||||
var cur strings.Builder
|
||||
inT, inRPh := false, 0
|
||||
_ = walkXML(ss, func(e xmlEvent) {
|
||||
switch {
|
||||
case e.start && e.name == "si":
|
||||
cur.Reset()
|
||||
case e.end && e.name == "si":
|
||||
shared = append(shared, cur.String())
|
||||
case e.start && e.name == "rPh":
|
||||
inRPh++ // phonetic guides, not the cell's text
|
||||
case e.end && e.name == "rPh":
|
||||
inRPh--
|
||||
case e.start && e.name == "t":
|
||||
inT = true
|
||||
case e.end && e.name == "t":
|
||||
inT = false
|
||||
case !e.start && !e.end && inT && inRPh == 0:
|
||||
cur.WriteString(e.text)
|
||||
}
|
||||
})
|
||||
}
|
||||
sheets, err := xlsxSheets(zr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var o out
|
||||
for _, sh := range sheets {
|
||||
data, err := member(zr, sh.path)
|
||||
if err != nil || data == nil {
|
||||
continue
|
||||
}
|
||||
o.heading(2)
|
||||
o.text(sh.name)
|
||||
o.para()
|
||||
var cell strings.Builder
|
||||
cellType, inV, inIs := "", false, false
|
||||
first := true
|
||||
_ = walkXML(data, func(e xmlEvent) {
|
||||
switch {
|
||||
case e.start && e.name == "row":
|
||||
first = true
|
||||
case e.end && e.name == "row":
|
||||
o.newline()
|
||||
case e.start && e.name == "c":
|
||||
cellType = e.get("t")
|
||||
cell.Reset()
|
||||
case e.end && e.name == "c":
|
||||
v := cell.String()
|
||||
if cellType == "s" {
|
||||
if i, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && i >= 0 && i < len(shared) {
|
||||
v = shared[i]
|
||||
}
|
||||
}
|
||||
v = strings.TrimSpace(strings.ReplaceAll(v, "\n", " "))
|
||||
if v != "" {
|
||||
if !first {
|
||||
o.tab()
|
||||
}
|
||||
o.text(v)
|
||||
first = false
|
||||
}
|
||||
case e.start && e.name == "v":
|
||||
inV = true
|
||||
case e.end && e.name == "v":
|
||||
inV = false
|
||||
case e.start && e.name == "is":
|
||||
inIs = true
|
||||
case e.end && e.name == "is":
|
||||
inIs = false
|
||||
case !e.start && !e.end && (inV || inIs):
|
||||
cell.WriteString(e.text)
|
||||
}
|
||||
})
|
||||
o.para()
|
||||
}
|
||||
return o.String(), nil
|
||||
}
|
||||
|
||||
type sheetRef struct{ name, path string }
|
||||
|
||||
// xlsxSheets lists a workbook's sheets in their order, with the part each is
|
||||
// kept in.
|
||||
func xlsxSheets(zr *zip.Reader) ([]sheetRef, error) {
|
||||
wb, err := member(zr, "xl/workbook.xml")
|
||||
if err != nil || wb == nil {
|
||||
return nil, errors.New("not a workbook: no xl/workbook.xml")
|
||||
}
|
||||
rels := map[string]string{}
|
||||
if r, _ := member(zr, "xl/_rels/workbook.xml.rels"); r != nil {
|
||||
_ = walkXML(r, func(e xmlEvent) {
|
||||
if e.start && e.name == "Relationship" {
|
||||
rels[e.get("Id")] = e.get("Target")
|
||||
}
|
||||
})
|
||||
}
|
||||
var out []sheetRef
|
||||
_ = walkXML(wb, func(e xmlEvent) {
|
||||
if !e.start || e.name != "sheet" {
|
||||
return
|
||||
}
|
||||
target := rels[e.relID()]
|
||||
if target == "" {
|
||||
return
|
||||
}
|
||||
p := path.Clean(path.Join("xl", target))
|
||||
if strings.HasPrefix(target, "/") {
|
||||
p = strings.TrimPrefix(target, "/")
|
||||
}
|
||||
out = append(out, sheetRef{name: e.get("name"), path: p})
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var slideNum = regexp.MustCompile(`(\d+)\.xml$`)
|
||||
|
||||
func numbered(zr *zip.Reader, prefix string) []*zip.File {
|
||||
var fs []*zip.File
|
||||
for _, f := range zr.File {
|
||||
if strings.HasPrefix(f.Name, prefix) && slideNum.MatchString(f.Name) && !strings.Contains(f.Name[len(prefix):], "/") {
|
||||
fs = append(fs, f)
|
||||
}
|
||||
}
|
||||
n := func(f *zip.File) int {
|
||||
v, _ := strconv.Atoi(slideNum.FindStringSubmatch(f.Name)[1])
|
||||
return v
|
||||
}
|
||||
sort.Slice(fs, func(i, j int) bool { return n(fs[i]) < n(fs[j]) })
|
||||
return fs
|
||||
}
|
||||
|
||||
// rels reads a part's relationships: id → target, and type → target, with
|
||||
// targets resolved against the part's directory.
|
||||
func rels(zr *zip.Reader, part string) (byID, byType map[string]string) {
|
||||
byID, byType = map[string]string{}, map[string]string{}
|
||||
dir, base := path.Split(part)
|
||||
r, _ := member(zr, dir+"_rels/"+base+".rels")
|
||||
if r == nil {
|
||||
return byID, byType
|
||||
}
|
||||
_ = walkXML(r, func(e xmlEvent) {
|
||||
if !e.start || e.name != "Relationship" || e.get("TargetMode") == "External" {
|
||||
return
|
||||
}
|
||||
t := e.get("Target")
|
||||
if strings.HasPrefix(t, "/") {
|
||||
t = strings.TrimPrefix(t, "/")
|
||||
} else {
|
||||
t = path.Clean(path.Join(dir, t))
|
||||
}
|
||||
byID[e.get("Id")] = t
|
||||
ty := e.get("Type")
|
||||
byType[ty[strings.LastIndex(ty, "/")+1:]] = t
|
||||
})
|
||||
return byID, byType
|
||||
}
|
||||
|
||||
// pptxText reads a presentation: each slide, in the presentation's order, as
|
||||
// a heading, its text, and its speaker notes.
|
||||
func pptxText(b []byte) (string, error) {
|
||||
zr, err := openZip(b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var order []string
|
||||
if pres, _ := member(zr, "ppt/presentation.xml"); pres != nil {
|
||||
ids, _ := rels(zr, "ppt/presentation.xml")
|
||||
_ = walkXML(pres, func(e xmlEvent) {
|
||||
if e.start && e.name == "sldId" {
|
||||
if t := ids[e.relID()]; t != "" {
|
||||
order = append(order, t)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
if len(order) == 0 {
|
||||
for _, f := range numbered(zr, "ppt/slides/slide") {
|
||||
order = append(order, f.Name)
|
||||
}
|
||||
}
|
||||
if len(order) == 0 {
|
||||
return "", errors.New("not a presentation: no slides")
|
||||
}
|
||||
var o out
|
||||
drawing := func(d []byte) {
|
||||
inT, inField := false, 0
|
||||
_ = walkXML(d, func(e xmlEvent) {
|
||||
switch {
|
||||
case e.start && e.name == "fld":
|
||||
// Slide numbers and dates, not the slide's text.
|
||||
inField++
|
||||
case e.end && e.name == "fld":
|
||||
inField--
|
||||
case e.start && e.name == "t":
|
||||
inT = true
|
||||
case e.end && e.name == "t":
|
||||
inT = false
|
||||
case e.end && e.name == "p":
|
||||
o.newline()
|
||||
case e.start && e.name == "br":
|
||||
o.newline()
|
||||
case e.end && e.name == "sp":
|
||||
o.para()
|
||||
case e.end && e.name == "tc":
|
||||
o.tab()
|
||||
case e.end && e.name == "tr":
|
||||
o.newline()
|
||||
case !e.start && !e.end && inT && inField == 0:
|
||||
o.raw(e.text)
|
||||
}
|
||||
})
|
||||
}
|
||||
for i, part := range order {
|
||||
d, err := member(zr, part)
|
||||
if err != nil || d == nil {
|
||||
continue
|
||||
}
|
||||
o.heading(2)
|
||||
o.text(fmt.Sprintf("Slide %d", i+1))
|
||||
o.para()
|
||||
drawing(d)
|
||||
if _, byType := rels(zr, part); byType["notesSlide"] != "" {
|
||||
if nd, _ := member(zr, byType["notesSlide"]); nd != nil {
|
||||
o.para()
|
||||
o.text("Notes:")
|
||||
o.newline()
|
||||
drawing(nd)
|
||||
}
|
||||
}
|
||||
o.para()
|
||||
}
|
||||
return o.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package extract
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf16"
|
||||
|
||||
"golang.org/x/text/encoding/charmap"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// rtfSkip are destinations whose content is not the document's text.
|
||||
var rtfSkip = map[string]bool{
|
||||
"fonttbl": true, "colortbl": true, "stylesheet": true, "info": true, "pict": true,
|
||||
"header": true, "headerl": true, "headerr": true, "headerf": true, "footer": true,
|
||||
"footerl": true, "footerr": true, "footerf": true, "object": true, "xmlnstbl": true,
|
||||
"listtable": true, "listoverridetable": true, "rsidtbl": true, "generator": true,
|
||||
"themedata": true, "colorschememapping": true, "latentstyles": true, "datastore": true,
|
||||
"filetbl": true, "revtbl": true, "pgdsctbl": true, "fldinst": true, "bkmkstart": true,
|
||||
"bkmkend": true, "shppict": true, "nonshppict": true, "datafield": true, "blipuid": true,
|
||||
}
|
||||
|
||||
// rtfText reads Rich Text Format: text, paragraphs, tabs and table cells,
|
||||
// characters in the document's code page (\ansicpg) or as \u escapes;
|
||||
// font tables, pictures, headers and other destinations skipped.
|
||||
func rtfText(b []byte) (string, error) {
|
||||
s := string(b)
|
||||
if !strings.HasPrefix(strings.TrimSpace(s), "{\\rtf") {
|
||||
return "", errors.New("not RTF: no {\\rtf header")
|
||||
}
|
||||
type state struct {
|
||||
skip bool
|
||||
uc int // how many fallback characters follow a \u
|
||||
}
|
||||
cp := charmap.Windows1252
|
||||
stack := []state{{uc: 1}}
|
||||
var o out
|
||||
var pending []byte // \'hh bytes, decoded in runs so a code page's pairs stay together
|
||||
var surrogate rune
|
||||
flush := func() {
|
||||
if len(pending) == 0 {
|
||||
return
|
||||
}
|
||||
d, _ := cp.NewDecoder().Bytes(pending)
|
||||
o.raw(string(d))
|
||||
pending = pending[:0]
|
||||
}
|
||||
skipChars := 0
|
||||
cur := func() *state { return &stack[len(stack)-1] }
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch c {
|
||||
case '{':
|
||||
flush()
|
||||
stack = append(stack, *cur())
|
||||
case '}':
|
||||
flush()
|
||||
if len(stack) > 1 {
|
||||
stack = stack[:len(stack)-1]
|
||||
}
|
||||
case '\\':
|
||||
if i+1 >= len(s) {
|
||||
break
|
||||
}
|
||||
n := s[i+1]
|
||||
switch {
|
||||
case n == '\'' && i+3 < len(s):
|
||||
v, err := strconv.ParseUint(s[i+2:i+4], 16, 8)
|
||||
i += 3
|
||||
if err != nil || cur().skip {
|
||||
continue
|
||||
}
|
||||
if skipChars > 0 {
|
||||
skipChars--
|
||||
continue
|
||||
}
|
||||
pending = append(pending, byte(v))
|
||||
case n == '*':
|
||||
i++
|
||||
cur().skip = true
|
||||
case n == '\\' || n == '{' || n == '}':
|
||||
i++
|
||||
if !cur().skip {
|
||||
flush()
|
||||
o.raw(string(n))
|
||||
}
|
||||
case n == '~':
|
||||
i++
|
||||
if !cur().skip {
|
||||
o.raw(" ")
|
||||
}
|
||||
case n == '-' || n == '_':
|
||||
i++
|
||||
case n == '\n' || n == '\r':
|
||||
i++
|
||||
if !cur().skip {
|
||||
flush()
|
||||
o.para()
|
||||
}
|
||||
case isLetter(n):
|
||||
j := i + 1
|
||||
for j < len(s) && isLetter(s[j]) {
|
||||
j++
|
||||
}
|
||||
word := s[i+1 : j]
|
||||
k := j
|
||||
if k < len(s) && (s[k] == '-' || (s[k] >= '0' && s[k] <= '9')) {
|
||||
k++
|
||||
for k < len(s) && s[k] >= '0' && s[k] <= '9' {
|
||||
k++
|
||||
}
|
||||
}
|
||||
arg, hasArg := 0, k > j
|
||||
if hasArg {
|
||||
arg, _ = strconv.Atoi(s[j:k])
|
||||
}
|
||||
if k < len(s) && s[k] == ' ' {
|
||||
k++
|
||||
}
|
||||
i = k - 1
|
||||
flush()
|
||||
if rtfSkip[word] {
|
||||
cur().skip = true
|
||||
continue
|
||||
}
|
||||
switch word {
|
||||
case "ansicpg":
|
||||
cp = codePage(arg)
|
||||
case "uc":
|
||||
cur().uc = arg
|
||||
case "u":
|
||||
if cur().skip {
|
||||
continue
|
||||
}
|
||||
r := rune(arg)
|
||||
if r < 0 {
|
||||
r += 65536
|
||||
}
|
||||
switch {
|
||||
case utf16.IsSurrogate(r) && r < 0xDC00:
|
||||
surrogate = r
|
||||
case utf16.IsSurrogate(r):
|
||||
o.raw(string(utf16.DecodeRune(surrogate, r)))
|
||||
surrogate = 0
|
||||
default:
|
||||
o.raw(string(r))
|
||||
}
|
||||
skipChars = cur().uc
|
||||
case "par", "sect", "page":
|
||||
if !cur().skip {
|
||||
o.para()
|
||||
}
|
||||
case "line":
|
||||
if !cur().skip {
|
||||
o.newline()
|
||||
}
|
||||
case "tab":
|
||||
if !cur().skip {
|
||||
o.raw("\t")
|
||||
}
|
||||
case "cell":
|
||||
if !cur().skip {
|
||||
o.tab()
|
||||
}
|
||||
case "row":
|
||||
if !cur().skip {
|
||||
o.newline()
|
||||
}
|
||||
case "emdash":
|
||||
o.raw("—")
|
||||
case "endash":
|
||||
o.raw("–")
|
||||
case "bullet":
|
||||
o.raw("•")
|
||||
case "lquote", "rquote":
|
||||
o.raw("'")
|
||||
case "ldblquote", "rdblquote":
|
||||
o.raw("\"")
|
||||
}
|
||||
default:
|
||||
i++
|
||||
}
|
||||
case '\r', '\n':
|
||||
// Line breaks in RTF source are not text.
|
||||
default:
|
||||
if cur().skip {
|
||||
continue
|
||||
}
|
||||
if skipChars > 0 {
|
||||
skipChars--
|
||||
continue
|
||||
}
|
||||
if c >= 0x80 {
|
||||
// Unescaped 8-bit text, as some writers leave it.
|
||||
pending = append(pending, c)
|
||||
continue
|
||||
}
|
||||
flush()
|
||||
o.raw(string(c))
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return norm.NFC.String(o.String()), nil
|
||||
}
|
||||
|
||||
func isLetter(c byte) bool { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') }
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,95 @@
|
||||
package extract
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/net/html/charset"
|
||||
"golang.org/x/text/encoding/charmap"
|
||||
"golang.org/x/text/encoding/unicode"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
func hasUTF16BOM(b []byte) bool {
|
||||
return len(b) >= 2 && ((b[0] == 0xFF && b[1] == 0xFE) || (b[0] == 0xFE && b[1] == 0xFF))
|
||||
}
|
||||
|
||||
// decodeText turns a text file into UTF-8, NFC.
|
||||
//
|
||||
// UTF-8 is taken as it is (its BOM dropped); UTF-16 by its BOM, which is how
|
||||
// Windows' Notepad writes "Unicode". Anything else is a single-byte code
|
||||
// page, and which one cannot be known from the bytes; Windows-1254 is
|
||||
// assumed, since it is what a Turkish Windows writes and it agrees with
|
||||
// Windows-1252 on every letter but six (ğĞıİşŞ in place of ðÐýÝþÞ) — which
|
||||
// are rare in the western languages 1252 serves.
|
||||
func decodeText(b []byte) string {
|
||||
var s string
|
||||
switch {
|
||||
case bytes.HasPrefix(b, []byte{0xEF, 0xBB, 0xBF}):
|
||||
s = string(b[3:])
|
||||
case hasUTF16BOM(b):
|
||||
out, err := unicode.UTF16(unicode.LittleEndian, unicode.ExpectBOM).NewDecoder().Bytes(b)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
s = string(out)
|
||||
case utf8.Valid(b):
|
||||
s = string(b)
|
||||
default:
|
||||
out, err := charmap.Windows1254.NewDecoder().Bytes(b)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
s = string(out)
|
||||
}
|
||||
return norm.NFC.String(strings.ReplaceAll(s, "\r\n", "\n"))
|
||||
}
|
||||
|
||||
// decodeCharset decodes bytes declared to be in a charset, as mail and web
|
||||
// pages declare theirs, by any of the WHATWG labels; an unknown or missing
|
||||
// one is treated as decodeText would.
|
||||
func decodeCharset(b []byte, cs string) string {
|
||||
cs = strings.ToLower(strings.TrimSpace(cs))
|
||||
if cs == "" || cs == "utf-8" || cs == "utf8" || cs == "us-ascii" {
|
||||
return decodeText(b)
|
||||
}
|
||||
enc, _ := charset.Lookup(cs)
|
||||
if enc == nil {
|
||||
return decodeText(b)
|
||||
}
|
||||
out, err := enc.NewDecoder().Bytes(b)
|
||||
if err != nil {
|
||||
return decodeText(b)
|
||||
}
|
||||
return norm.NFC.String(strings.ReplaceAll(string(out), "\r\n", "\n"))
|
||||
}
|
||||
|
||||
// codePage is the decoder for an RTF \ansicpgN or a Windows code page number.
|
||||
func codePage(n int) *charmap.Charmap {
|
||||
switch n {
|
||||
case 1250:
|
||||
return charmap.Windows1250
|
||||
case 1251:
|
||||
return charmap.Windows1251
|
||||
case 1252:
|
||||
return charmap.Windows1252
|
||||
case 1253:
|
||||
return charmap.Windows1253
|
||||
case 1254:
|
||||
return charmap.Windows1254
|
||||
case 1255:
|
||||
return charmap.Windows1255
|
||||
case 1256:
|
||||
return charmap.Windows1256
|
||||
case 1257:
|
||||
return charmap.Windows1257
|
||||
case 1258:
|
||||
return charmap.Windows1258
|
||||
case 850:
|
||||
return charmap.CodePage850
|
||||
case 437:
|
||||
return charmap.CodePage437
|
||||
}
|
||||
return charmap.Windows1252
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
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"`
|
||||
OwnerName string `json:"owner_name"`
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// Package kbsync is llmgw-kb's work (BACKLOG F2): it finds a person's
|
||||
// documents, keeps a ledger of what it has done with each, and sends a
|
||||
// knowledge base only what changed — text and vectors, never the files.
|
||||
package kbsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite" // pure Go, so the client builds for every system
|
||||
)
|
||||
|
||||
// Ledger is the record, on this machine, of every document sent or skipped:
|
||||
// which file it came from, its size, modification time and digest, and what
|
||||
// happened. A file whose size and time have not changed is not read again to
|
||||
// learn that it has not changed, and --delete removes only what this
|
||||
// machine sent.
|
||||
type Ledger struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// State is what happened to a document.
|
||||
type State string
|
||||
|
||||
// The states a document is left in.
|
||||
const (
|
||||
Sent State = "sent"
|
||||
Skipped State = "skipped"
|
||||
Failed State = "failed"
|
||||
)
|
||||
|
||||
// Entry is one document in the ledger.
|
||||
type Entry struct {
|
||||
Gateway string
|
||||
Collection string
|
||||
// Name is the document's name in the collection.
|
||||
Name string
|
||||
// File is the path on disk it came from, absolute.
|
||||
File string
|
||||
Size int64
|
||||
MTime time.Time
|
||||
Digest string
|
||||
State State
|
||||
Reason string
|
||||
Chunks int
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// Run is one sync, for history.
|
||||
type Run struct {
|
||||
ID int64
|
||||
Gateway, Collection string
|
||||
Started, Finished time.Time
|
||||
Sent, Unchanged, Skipped, Failed, Delete int
|
||||
}
|
||||
|
||||
// DefaultLedgerPath is where the ledger is kept: the user's configuration
|
||||
// directory, which, unlike a cache directory, nothing cleans up.
|
||||
func DefaultLedgerPath() (string, error) {
|
||||
dir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "llmgw-kb", "ledger.db"), nil
|
||||
}
|
||||
|
||||
const ledgerSchema = `
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
gateway TEXT NOT NULL,
|
||||
collection TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
file TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
mtime_ns INTEGER NOT NULL,
|
||||
digest TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
chunks INTEGER NOT NULL DEFAULT 0,
|
||||
at_ms INTEGER NOT NULL,
|
||||
PRIMARY KEY (gateway, collection, name)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS documents_by_file ON documents (file);
|
||||
CREATE TABLE IF NOT EXISTS runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
gateway TEXT NOT NULL,
|
||||
collection TEXT NOT NULL,
|
||||
started_ms INTEGER NOT NULL,
|
||||
ended_ms INTEGER NOT NULL DEFAULT 0,
|
||||
sent INTEGER NOT NULL DEFAULT 0,
|
||||
unchanged INTEGER NOT NULL DEFAULT 0,
|
||||
skipped INTEGER NOT NULL DEFAULT 0,
|
||||
failed INTEGER NOT NULL DEFAULT 0,
|
||||
deleted INTEGER NOT NULL DEFAULT 0
|
||||
);`
|
||||
|
||||
// OpenLedger opens (creating) the ledger at a path.
|
||||
func OpenLedger(ctx context.Context, path string) (*Ledger, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// WAL and a busy timeout: two syncs at once, from two terminals, wait
|
||||
// for each other rather than failing.
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(10000)&_pragma=synchronous(NORMAL)")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
if _, err := db.ExecContext(ctx, ledgerSchema); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("opening the ledger %s: %w", path, err)
|
||||
}
|
||||
_ = os.Chmod(path, 0o600)
|
||||
return &Ledger{db: db}, nil
|
||||
}
|
||||
|
||||
// Close closes the ledger.
|
||||
func (l *Ledger) Close() error { return l.db.Close() }
|
||||
|
||||
const entryCols = `gateway, collection, name, file, size, mtime_ns, digest, state, reason, chunks, at_ms`
|
||||
|
||||
func scanEntry(sc interface{ Scan(...any) error }) (Entry, error) {
|
||||
var e Entry
|
||||
var mtime, at int64
|
||||
var state string
|
||||
err := sc.Scan(&e.Gateway, &e.Collection, &e.Name, &e.File, &e.Size, &mtime, &e.Digest, &state, &e.Reason, &e.Chunks, &at)
|
||||
e.MTime, e.At, e.State = time.Unix(0, mtime), time.UnixMilli(at), State(state)
|
||||
return e, err
|
||||
}
|
||||
|
||||
// Put records what happened to a document.
|
||||
func (l *Ledger) Put(ctx context.Context, e Entry) error {
|
||||
if e.At.IsZero() {
|
||||
e.At = time.Now()
|
||||
}
|
||||
_, err := l.db.ExecContext(ctx, `INSERT INTO documents (`+entryCols+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (gateway, collection, name) DO UPDATE SET file = excluded.file, size = excluded.size,
|
||||
mtime_ns = excluded.mtime_ns, digest = excluded.digest, state = excluded.state,
|
||||
reason = excluded.reason, chunks = excluded.chunks, at_ms = excluded.at_ms`,
|
||||
e.Gateway, e.Collection, e.Name, e.File, e.Size, e.MTime.UnixNano(), e.Digest, string(e.State),
|
||||
e.Reason, e.Chunks, e.At.UnixMilli())
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove forgets a document.
|
||||
func (l *Ledger) Remove(ctx context.Context, gateway, collection, name string) error {
|
||||
_, err := l.db.ExecContext(ctx, `DELETE FROM documents WHERE gateway = ? AND collection = ? AND name = ?`,
|
||||
gateway, collection, name)
|
||||
return err
|
||||
}
|
||||
|
||||
// Entries lists a collection's documents as this machine left them.
|
||||
func (l *Ledger) Entries(ctx context.Context, gateway, collection string) ([]Entry, error) {
|
||||
rows, err := l.db.QueryContext(ctx, `SELECT `+entryCols+` FROM documents WHERE gateway = ? AND collection = ? ORDER BY name`,
|
||||
gateway, collection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Entry
|
||||
for rows.Next() {
|
||||
e, err := scanEntry(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Known is the digest the ledger has for a file of this size and time, from
|
||||
// any collection: a file is hashed once however many collections it goes to.
|
||||
func (l *Ledger) Known(ctx context.Context, file string, size int64, mtime time.Time) (string, bool) {
|
||||
var d string
|
||||
err := l.db.QueryRowContext(ctx, `SELECT digest FROM documents WHERE file = ? AND size = ? AND mtime_ns = ? LIMIT 1`,
|
||||
file, size, mtime.UnixNano()).Scan(&d)
|
||||
return d, err == nil
|
||||
}
|
||||
|
||||
// StartRun records that a sync began.
|
||||
func (l *Ledger) StartRun(ctx context.Context, gateway, collection string) (int64, error) {
|
||||
res, err := l.db.ExecContext(ctx, `INSERT INTO runs (gateway, collection, started_ms) VALUES (?, ?, ?)`,
|
||||
gateway, collection, time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// EndRun records how a sync ended.
|
||||
func (l *Ledger) EndRun(ctx context.Context, id int64, r Run) error {
|
||||
_, err := l.db.ExecContext(ctx, `UPDATE runs SET ended_ms = ?, sent = ?, unchanged = ?, skipped = ?, failed = ?, deleted = ?
|
||||
WHERE id = ?`, time.Now().UnixMilli(), r.Sent, r.Unchanged, r.Skipped, r.Failed, r.Delete, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// Runs lists the latest syncs, newest first.
|
||||
func (l *Ledger) Runs(ctx context.Context, limit int) ([]Run, error) {
|
||||
rows, err := l.db.QueryContext(ctx, `SELECT id, gateway, collection, started_ms, ended_ms, sent, unchanged, skipped, failed, deleted
|
||||
FROM runs ORDER BY id DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Run
|
||||
for rows.Next() {
|
||||
var r Run
|
||||
var s, e int64
|
||||
if err := rows.Scan(&r.ID, &r.Gateway, &r.Collection, &s, &e, &r.Sent, &r.Unchanged, &r.Skipped, &r.Failed, &r.Delete); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Started = time.UnixMilli(s)
|
||||
if e > 0 {
|
||||
r.Finished = time.UnixMilli(e)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
package kbsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/unicode/norm"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/kb/chunk"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/kb/extract"
|
||||
)
|
||||
|
||||
// Options say what to sync and how.
|
||||
type Options struct {
|
||||
// Collection is the collection's name or id.
|
||||
Collection string
|
||||
// Roots are the directories and files to sync.
|
||||
Roots []string
|
||||
// Include, when set, are the only files read (globs; one without a "/"
|
||||
// matches a file's name, one with it the path under its root, "**"
|
||||
// crossing directories). Exclude are never read.
|
||||
Include, Exclude []string
|
||||
// Hidden includes files and directories whose names start with a dot.
|
||||
Hidden bool
|
||||
// DryRun reads and cuts everything that would be sent, and sends
|
||||
// nothing.
|
||||
DryRun bool
|
||||
// Delete removes from the collection documents this machine sent whose
|
||||
// files under these roots are gone.
|
||||
Delete bool
|
||||
// Retry reads again files skipped before, as when a helper has been
|
||||
// installed since.
|
||||
Retry bool
|
||||
// Workers is how many files are read and sent at once; 0 is 4.
|
||||
Workers int
|
||||
Extract extract.Options
|
||||
Chunk chunk.Options
|
||||
}
|
||||
|
||||
// Event is something a sync did to one document, for showing progress.
|
||||
type Event struct {
|
||||
// Kind is sent, unchanged, skipped, failed, deleted, would-send,
|
||||
// would-delete, or warning.
|
||||
Kind string
|
||||
Name string
|
||||
Detail string
|
||||
Chunks int
|
||||
Bytes int64
|
||||
}
|
||||
|
||||
// Report is what a sync did in all.
|
||||
type Report struct {
|
||||
Collection Collection
|
||||
Files int
|
||||
Sent int
|
||||
Unchanged int
|
||||
Skipped int
|
||||
Failed int
|
||||
Deleted int
|
||||
// Chunks and Bytes are what was sent, or with DryRun what would be.
|
||||
Chunks int
|
||||
Bytes int64
|
||||
}
|
||||
|
||||
// maxDocJSON is the most one document's request is, under the gateway's
|
||||
// limit with room to spare.
|
||||
const maxDocJSON = 60 << 20
|
||||
|
||||
// defaultExcludes are directories nobody means to put in a knowledge base.
|
||||
var defaultExcludes = map[string]bool{
|
||||
".git": true, ".svn": true, ".hg": true, "node_modules": true, ".venv": true, "venv": true,
|
||||
"__pycache__": true, ".idea": true, ".vscode": true, "$RECYCLE.BIN": true,
|
||||
"System Volume Information": true, ".Trash": true, ".Trashes": true, ".cache": true,
|
||||
}
|
||||
|
||||
type file struct {
|
||||
abs string
|
||||
name string
|
||||
size int64
|
||||
mtime time.Time
|
||||
}
|
||||
|
||||
// Sync brings a collection up to date with the files under the roots.
|
||||
func Sync(ctx context.Context, c *Client, l *Ledger, o Options, progress func(Event)) (Report, error) {
|
||||
var mu sync.Mutex
|
||||
var rep Report
|
||||
emit := func(e Event) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
switch e.Kind {
|
||||
case "sent", "would-send":
|
||||
rep.Sent++
|
||||
rep.Chunks += e.Chunks
|
||||
rep.Bytes += e.Bytes
|
||||
case "unchanged":
|
||||
rep.Unchanged++
|
||||
case "skipped":
|
||||
rep.Skipped++
|
||||
case "failed":
|
||||
rep.Failed++
|
||||
case "deleted", "would-delete":
|
||||
rep.Deleted++
|
||||
}
|
||||
if progress != nil {
|
||||
progress(e)
|
||||
}
|
||||
}
|
||||
|
||||
m, err := c.Manifest(ctx, o.Collection)
|
||||
if err != nil {
|
||||
return rep, fmt.Errorf("reading collection %s: %w", o.Collection, err)
|
||||
}
|
||||
rep.Collection = m.Collection
|
||||
if !m.Collection.Writable && !o.DryRun {
|
||||
return rep, fmt.Errorf("this key may read collection %s but not add to it", m.Collection.Name)
|
||||
}
|
||||
remote, chunks := map[string]string{}, map[string]int{}
|
||||
for _, d := range m.Documents {
|
||||
remote[d.Name], chunks[d.Name] = d.Digest, d.Chunks
|
||||
}
|
||||
gw := strings.TrimRight(c.Base, "/")
|
||||
coll := m.Collection.ID
|
||||
|
||||
files, roots, err := walk(o, func(name, why string) { emit(Event{Kind: "warning", Name: name, Detail: why}) })
|
||||
if err != nil {
|
||||
return rep, err
|
||||
}
|
||||
rep.Files = len(files)
|
||||
if len(files) == 0 {
|
||||
return rep, ErrNothing
|
||||
}
|
||||
|
||||
runID, err := l.StartRun(ctx, gw, coll)
|
||||
if err != nil {
|
||||
return rep, err
|
||||
}
|
||||
previous, err := l.Entries(ctx, gw, coll)
|
||||
if err != nil {
|
||||
return rep, err
|
||||
}
|
||||
byFile := map[string][]Entry{}
|
||||
for _, e := range previous {
|
||||
byFile[e.File] = append(byFile[e.File], e)
|
||||
}
|
||||
|
||||
s := &syncer{c: c, l: l, o: o, gw: gw, coll: m.Collection, remote: remote, chunks: chunks, byFile: byFile, emit: emit}
|
||||
workers := o.Workers
|
||||
if workers <= 0 {
|
||||
workers = 4
|
||||
}
|
||||
jobs := make(chan file)
|
||||
var wg sync.WaitGroup
|
||||
for range workers {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for f := range jobs {
|
||||
s.one(ctx, f)
|
||||
}
|
||||
}()
|
||||
}
|
||||
for _, f := range files {
|
||||
select {
|
||||
case jobs <- f:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
close(jobs)
|
||||
wg.Wait()
|
||||
|
||||
if o.Delete && ctx.Err() == nil {
|
||||
seen := map[string]bool{}
|
||||
for _, f := range files {
|
||||
seen[f.abs] = true
|
||||
}
|
||||
for _, e := range previous {
|
||||
if e.State != Sent || seen[e.File] || !under(e.File, roots) {
|
||||
continue
|
||||
}
|
||||
if o.DryRun {
|
||||
emit(Event{Kind: "would-delete", Name: e.Name})
|
||||
continue
|
||||
}
|
||||
if err := c.Delete(ctx, coll, e.Name); err != nil {
|
||||
emit(Event{Kind: "failed", Name: e.Name, Detail: "deleting: " + err.Error()})
|
||||
continue
|
||||
}
|
||||
_ = l.Remove(ctx, gw, coll, e.Name)
|
||||
emit(Event{Kind: "deleted", Name: e.Name})
|
||||
}
|
||||
}
|
||||
_ = l.EndRun(context.WithoutCancel(ctx), runID, Run{Sent: rep.Sent, Unchanged: rep.Unchanged, Skipped: rep.Skipped,
|
||||
Failed: rep.Failed, Delete: rep.Deleted})
|
||||
return rep, ctx.Err()
|
||||
}
|
||||
|
||||
type syncer struct {
|
||||
c *Client
|
||||
l *Ledger
|
||||
o Options
|
||||
gw string
|
||||
coll Collection
|
||||
remote map[string]string
|
||||
chunks map[string]int
|
||||
byFile map[string][]Entry
|
||||
emit func(Event)
|
||||
}
|
||||
|
||||
func (s *syncer) entry(f file, name, digest string, st State, reason string, chunks int) Entry {
|
||||
return Entry{Gateway: s.gw, Collection: s.coll.ID, Name: name, File: f.abs, Size: f.size, MTime: f.mtime,
|
||||
Digest: digest, State: st, Reason: reason, Chunks: chunks}
|
||||
}
|
||||
|
||||
// one syncs one file: every document in it.
|
||||
func (s *syncer) one(ctx context.Context, f file) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
digest, ok := s.l.Known(ctx, f.abs, f.size, f.mtime)
|
||||
if !ok {
|
||||
d, err := hashFile(f.abs)
|
||||
if err != nil {
|
||||
s.emit(Event{Kind: "skipped", Name: f.name, Detail: err.Error()})
|
||||
return
|
||||
}
|
||||
digest = d
|
||||
}
|
||||
prev := s.byFile[f.abs]
|
||||
|
||||
// The collection has this file as it is: nothing to read.
|
||||
if s.remote[f.name] == digest {
|
||||
s.record(ctx, s.entry(f, f.name, digest, Sent, "", s.chunks[f.name]))
|
||||
s.emit(Event{Kind: "unchanged", Name: f.name})
|
||||
return
|
||||
}
|
||||
// An archive or a mail is several documents; all of them as they are
|
||||
// is nothing to read either.
|
||||
if len(prev) > 0 && allSame(prev, digest, s.remote) && !(s.o.Retry && anySkipped(prev)) {
|
||||
for _, e := range prev {
|
||||
if e.State == Skipped {
|
||||
s.emit(Event{Kind: "skipped", Name: e.Name, Detail: e.Reason + " (as before; --retry reads it again)"})
|
||||
} else {
|
||||
s.emit(Event{Kind: "unchanged", Name: e.Name})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
parts, err := extract.File(ctx, f.abs, s.o.Extract)
|
||||
if err != nil {
|
||||
s.skip(ctx, f, f.name, digest, err)
|
||||
return
|
||||
}
|
||||
current := map[string]bool{}
|
||||
for _, p := range parts {
|
||||
name := f.name
|
||||
if p.Name != "" {
|
||||
name = f.name + "!/" + p.Name
|
||||
}
|
||||
current[name] = true
|
||||
if p.Err != nil {
|
||||
s.skip(ctx, f, name, digest, p.Err)
|
||||
continue
|
||||
}
|
||||
s.send(ctx, f, name, digest, p.Text)
|
||||
}
|
||||
// Members an archive no longer has go with it.
|
||||
for _, e := range prev {
|
||||
if current[e.Name] || e.Name == f.name {
|
||||
continue
|
||||
}
|
||||
if s.o.DryRun {
|
||||
s.emit(Event{Kind: "would-delete", Name: e.Name, Detail: "no longer in " + f.name})
|
||||
continue
|
||||
}
|
||||
if e.State == Sent {
|
||||
if err := s.c.Delete(ctx, s.coll.ID, e.Name); err != nil {
|
||||
s.emit(Event{Kind: "failed", Name: e.Name, Detail: "deleting: " + err.Error()})
|
||||
continue
|
||||
}
|
||||
s.emit(Event{Kind: "deleted", Name: e.Name, Detail: "no longer in " + f.name})
|
||||
}
|
||||
_ = s.l.Remove(ctx, s.gw, s.coll.ID, e.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// allSame reports whether every document from a file was left with this
|
||||
// digest, and the collection still holds those that were sent.
|
||||
func allSame(prev []Entry, digest string, remote map[string]string) bool {
|
||||
for _, e := range prev {
|
||||
if e.Digest != digest {
|
||||
return false
|
||||
}
|
||||
switch e.State {
|
||||
case Sent:
|
||||
if remote[e.Name] != digest {
|
||||
return false
|
||||
}
|
||||
case Failed:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func anySkipped(es []Entry) bool {
|
||||
for _, e := range es {
|
||||
if e.State == Skipped {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *syncer) skip(ctx context.Context, f file, name, digest string, err error) {
|
||||
s.record(ctx, s.entry(f, name, digest, Skipped, err.Error(), 0))
|
||||
s.emit(Event{Kind: "skipped", Name: name, Detail: err.Error()})
|
||||
}
|
||||
|
||||
// record writes the ledger; a dry run leaves it as it was.
|
||||
func (s *syncer) record(ctx context.Context, e Entry) {
|
||||
if s.o.DryRun {
|
||||
return
|
||||
}
|
||||
_ = s.l.Put(ctx, e)
|
||||
}
|
||||
|
||||
// send cuts a document's text, embeds it and sends it.
|
||||
func (s *syncer) send(ctx context.Context, f file, name, digest, text string) {
|
||||
if s.remote[name] == digest {
|
||||
s.record(ctx, s.entry(f, name, digest, Sent, "", s.chunks[name]))
|
||||
s.emit(Event{Kind: "unchanged", Name: name})
|
||||
return
|
||||
}
|
||||
cs := chunk.Split(text, s.o.Chunk)
|
||||
if len(cs) == 0 {
|
||||
s.skip(ctx, f, name, digest, extract.ErrNoText)
|
||||
return
|
||||
}
|
||||
texts := make([]string, len(cs))
|
||||
var size int64
|
||||
for i, c := range cs {
|
||||
texts[i] = c.Text()
|
||||
size += int64(len(texts[i]))
|
||||
}
|
||||
// About ten bytes of JSON a dimension.
|
||||
size += int64(len(cs) * s.coll.Dims * 10)
|
||||
if size > maxDocJSON {
|
||||
s.fail(ctx, f, name, digest, fmt.Errorf("%d MiB to send is over the gateway's limit for one document; split the file", size>>20))
|
||||
return
|
||||
}
|
||||
if s.o.DryRun {
|
||||
s.emit(Event{Kind: "would-send", Name: name, Chunks: len(cs), Bytes: size})
|
||||
return
|
||||
}
|
||||
vecs, err := s.c.Embed(ctx, s.coll.EmbedModel, texts)
|
||||
if err != nil {
|
||||
s.fail(ctx, f, name, digest, fmt.Errorf("embedding with %s: %w", s.coll.EmbedModel, err))
|
||||
return
|
||||
}
|
||||
out := DocOut{Name: name, Digest: digest, Bytes: f.size, Chunks: make([]ChunkOut, len(cs))}
|
||||
for i := range cs {
|
||||
if len(vecs[i]) != s.coll.Dims {
|
||||
s.fail(ctx, f, name, digest, fmt.Errorf("%s gave %d dimensions and the collection takes %d",
|
||||
s.coll.EmbedModel, len(vecs[i]), s.coll.Dims))
|
||||
return
|
||||
}
|
||||
out.Chunks[i] = ChunkOut{Text: texts[i], Embedding: vecs[i]}
|
||||
}
|
||||
if _, err := s.c.Put(ctx, s.coll.ID, out); err != nil {
|
||||
s.fail(ctx, f, name, digest, err)
|
||||
return
|
||||
}
|
||||
s.record(ctx, s.entry(f, name, digest, Sent, "", len(cs)))
|
||||
s.emit(Event{Kind: "sent", Name: name, Chunks: len(cs), Bytes: size})
|
||||
}
|
||||
|
||||
func (s *syncer) fail(ctx context.Context, f file, name, digest string, err error) {
|
||||
s.record(ctx, s.entry(f, name, digest, Failed, err.Error(), 0))
|
||||
s.emit(Event{Kind: "failed", Name: name, Detail: err.Error()})
|
||||
}
|
||||
|
||||
func hashFile(p string) (string, error) {
|
||||
fh, err := os.Open(p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer fh.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, fh); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "sha256:" + hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// under reports whether a path is one of the roots or inside one.
|
||||
func under(p string, roots []string) bool {
|
||||
for _, r := range roots {
|
||||
if p == r || strings.HasPrefix(p, strings.TrimRight(r, string(filepath.Separator))+string(filepath.Separator)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// walk lists the files to sync, named as documents: the path under its root
|
||||
// with forward slashes, in Unicode NFC, prefixed by the root's own name when
|
||||
// there are several roots so that two roots' "README.md" stay apart.
|
||||
func walk(o Options, warn func(name, why string)) ([]file, []string, error) {
|
||||
inc, err := globs(o.Include)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
exc, err := globs(o.Exclude)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var out []file
|
||||
var roots []string
|
||||
seen := map[string]string{}
|
||||
for _, root := range o.Roots {
|
||||
abs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
st, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
roots = append(roots, abs)
|
||||
prefix := ""
|
||||
if len(o.Roots) > 1 && st.IsDir() {
|
||||
prefix = norm.NFC.String(filepath.Base(abs)) + "/"
|
||||
}
|
||||
add := func(p string, info fs.FileInfo) {
|
||||
rel := filepath.Base(p)
|
||||
if st.IsDir() {
|
||||
rel, _ = filepath.Rel(abs, p)
|
||||
}
|
||||
name := prefix + norm.NFC.String(filepath.ToSlash(rel))
|
||||
if !wanted(name, inc, exc, o.Hidden) {
|
||||
return
|
||||
}
|
||||
if prev, ok := seen[strings.ToLower(name)]; ok && prev != name {
|
||||
warn(name, "differs from "+prev+" only in case; on Windows and macOS they are one file")
|
||||
}
|
||||
seen[strings.ToLower(name)] = name
|
||||
out = append(out, file{abs: p, name: name, size: info.Size(), mtime: info.ModTime()})
|
||||
}
|
||||
if !st.IsDir() {
|
||||
add(abs, st)
|
||||
continue
|
||||
}
|
||||
err = filepath.WalkDir(abs, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
// A directory this user may not read is skipped, not fatal.
|
||||
warn(p, err.Error())
|
||||
if d != nil && d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
base := d.Name()
|
||||
if d.IsDir() {
|
||||
if p != abs && (defaultExcludes[base] || (!o.Hidden && strings.HasPrefix(base, "."))) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !d.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
add(p, info)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].name < out[j].name })
|
||||
return out, roots, nil
|
||||
}
|
||||
|
||||
// wanted applies the filters to a document name.
|
||||
func wanted(name string, inc, exc []*regexp.Regexp, hidden bool) bool {
|
||||
base := path.Base(name)
|
||||
if !hidden && strings.HasPrefix(base, ".") {
|
||||
return false
|
||||
}
|
||||
// Office's lock files, LibreOffice's, editors' backups.
|
||||
if strings.HasPrefix(base, "~$") || strings.HasPrefix(base, ".~lock.") || strings.HasSuffix(base, "~") ||
|
||||
base == "Thumbs.db" || base == "desktop.ini" || base == ".DS_Store" {
|
||||
return false
|
||||
}
|
||||
for _, re := range exc {
|
||||
if re.MatchString(name) || re.MatchString(base) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if len(inc) > 0 {
|
||||
for _, re := range inc {
|
||||
if re.MatchString(name) || re.MatchString(base) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return extract.Known(base)
|
||||
}
|
||||
|
||||
// globs compiles glob patterns: "*" within a name, "**" across directories,
|
||||
// "?" one character; case-insensitive, as names are on Windows and macOS.
|
||||
func globs(patterns []string) ([]*regexp.Regexp, error) {
|
||||
var out []*regexp.Regexp
|
||||
for _, p := range patterns {
|
||||
for _, one := range strings.Split(p, ",") {
|
||||
one = strings.TrimSpace(filepath.ToSlash(one))
|
||||
if one == "" {
|
||||
continue
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("(?i)^")
|
||||
for i := 0; i < len(one); i++ {
|
||||
switch c := one[i]; c {
|
||||
case '*':
|
||||
if i+1 < len(one) && one[i+1] == '*' {
|
||||
b.WriteString(".*")
|
||||
i++
|
||||
if i+1 < len(one) && one[i+1] == '/' {
|
||||
b.WriteString("/?")
|
||||
i++
|
||||
}
|
||||
} else {
|
||||
b.WriteString("[^/]*")
|
||||
}
|
||||
case '?':
|
||||
b.WriteString("[^/]")
|
||||
default:
|
||||
b.WriteString(regexp.QuoteMeta(string(c)))
|
||||
}
|
||||
}
|
||||
b.WriteString("$")
|
||||
re, err := regexp.Compile(b.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pattern %q: %w", one, err)
|
||||
}
|
||||
out = append(out, re)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ErrNothing is a sync that found no file to read.
|
||||
var ErrNothing = errors.New("no files to read under these paths")
|
||||
@@ -0,0 +1,272 @@
|
||||
package kbsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeGateway holds one collection in memory and embeds by counting letters.
|
||||
type fakeGateway struct {
|
||||
mu sync.Mutex
|
||||
writable bool
|
||||
docs map[string]RemoteDoc
|
||||
puts int
|
||||
embedded int
|
||||
busyFirst bool
|
||||
}
|
||||
|
||||
func (g *fakeGateway) serve(t *testing.T) *Client {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
coll := Collection{ID: "kb_x", Name: "ik", EmbedModel: "emb", Dims: 3}
|
||||
mux.HandleFunc("GET /v1/kb/collections/{id}/manifest", func(w http.ResponseWriter, r *http.Request) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if r.PathValue("id") != "ik" && r.PathValue("id") != "kb_x" {
|
||||
http.Error(w, `{"error":{"message":"no collection"}}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
m := Manifest{Collection: coll}
|
||||
m.Collection.Writable = g.writable
|
||||
for _, d := range g.docs {
|
||||
m.Documents = append(m.Documents, d)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(m)
|
||||
})
|
||||
mux.HandleFunc("PUT /v1/kb/collections/{id}/documents", func(w http.ResponseWriter, r *http.Request) {
|
||||
var d DocOut
|
||||
_ = json.NewDecoder(r.Body).Decode(&d)
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.puts++
|
||||
g.docs[d.Name] = RemoteDoc{Name: d.Name, Digest: d.Digest, Chunks: len(d.Chunks)}
|
||||
_, _ = w.Write([]byte(`{"changed":true}`))
|
||||
})
|
||||
mux.HandleFunc("DELETE /v1/kb/collections/{id}/documents", func(w http.ResponseWriter, r *http.Request) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
delete(g.docs, r.URL.Query().Get("name"))
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
})
|
||||
mux.HandleFunc("POST /v1/embeddings", func(w http.ResponseWriter, r *http.Request) {
|
||||
g.mu.Lock()
|
||||
busy := g.busyFirst
|
||||
g.busyFirst = false
|
||||
g.mu.Unlock()
|
||||
if busy {
|
||||
w.Header().Set("Retry-After", "1")
|
||||
http.Error(w, `{"error":{"message":"the model is loading"}}`, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Input []string `json:"input"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&in)
|
||||
type row struct {
|
||||
Index int `json:"index"`
|
||||
Embedding []float32 `json:"embedding"`
|
||||
}
|
||||
var out struct {
|
||||
Data []row `json:"data"`
|
||||
}
|
||||
for i, s := range in.Input {
|
||||
out.Data = append(out.Data, row{i, []float32{float32(len(s)), 1, 0}})
|
||||
}
|
||||
g.mu.Lock()
|
||||
g.embedded += len(in.Input)
|
||||
g.mu.Unlock()
|
||||
_ = json.NewEncoder(w).Encode(out)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
h, _ := NewHTTP("")
|
||||
return &Client{Base: srv.URL, Key: "k", HTTP: h}
|
||||
}
|
||||
|
||||
func setup(t *testing.T) (*fakeGateway, *Client, *Ledger, string) {
|
||||
t.Helper()
|
||||
g := &fakeGateway{writable: true, docs: map[string]RemoteDoc{}}
|
||||
c := g.serve(t)
|
||||
l, err := OpenLedger(context.Background(), filepath.Join(t.TempDir(), "ledger.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = l.Close() })
|
||||
dir := t.TempDir()
|
||||
write(t, dir, "politika/izin.md", "# İzin\n\nYirmi gün.\n")
|
||||
write(t, dir, "politika/masraf.txt", "Masraflar finansa.\n")
|
||||
write(t, dir, "eski.doc", "\xd0\xcf\x11\xe0\x00binary")
|
||||
write(t, dir, "resim.bin", "\x00\x01")
|
||||
write(t, dir, ".git/config", "[core]")
|
||||
write(t, dir, "politika/~$izin.docx", "lock")
|
||||
return g, c, l, dir
|
||||
}
|
||||
|
||||
func write(t *testing.T, dir, name, s string) {
|
||||
t.Helper()
|
||||
p := filepath.Join(dir, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(s), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func kinds(events []Event) map[string][]string {
|
||||
out := map[string][]string{}
|
||||
for _, e := range events {
|
||||
out[e.Kind] = append(out[e.Kind], e.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func run(t *testing.T, c *Client, l *Ledger, o Options) (Report, map[string][]string) {
|
||||
t.Helper()
|
||||
var mu sync.Mutex
|
||||
var ev []Event
|
||||
rep, err := Sync(context.Background(), c, l, o, func(e Event) {
|
||||
mu.Lock()
|
||||
ev = append(ev, e)
|
||||
mu.Unlock()
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return rep, kinds(ev)
|
||||
}
|
||||
|
||||
func TestASecondRunSendsNothingAndReadsNothing(t *testing.T) {
|
||||
g, c, l, dir := setup(t)
|
||||
rep, k := run(t, c, l, Options{Collection: "ik", Roots: []string{dir}})
|
||||
if rep.Sent != 2 || len(g.docs) != 2 {
|
||||
t.Fatalf("first run %+v %v", rep, k)
|
||||
}
|
||||
if _, ok := g.docs["politika/izin.md"]; !ok {
|
||||
t.Errorf("documents %v", g.docs)
|
||||
}
|
||||
// The legacy Word file is skipped with the reason, and no hidden,
|
||||
// lock or unknown file is touched.
|
||||
if len(k["skipped"]) != 1 || k["skipped"][0] != "eski.doc" {
|
||||
t.Errorf("skipped %v", k["skipped"])
|
||||
}
|
||||
|
||||
puts, embedded := g.puts, g.embedded
|
||||
rep, k = run(t, c, l, Options{Collection: "ik", Roots: []string{dir}})
|
||||
if rep.Sent != 0 || rep.Unchanged != 2 || g.puts != puts || g.embedded != embedded {
|
||||
t.Errorf("second run %+v %v", rep, k)
|
||||
}
|
||||
if rep.Skipped != 1 {
|
||||
t.Errorf("the skipped file was not reported as before: %+v", rep)
|
||||
}
|
||||
es, _ := l.Entries(context.Background(), c.Base, "kb_x")
|
||||
if len(es) != 3 {
|
||||
t.Errorf("ledger %+v", es)
|
||||
}
|
||||
runs, _ := l.Runs(context.Background(), 10)
|
||||
if len(runs) != 2 || runs[0].Unchanged != 2 {
|
||||
t.Errorf("runs %+v", runs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAChangedFileIsSentAgain(t *testing.T) {
|
||||
g, c, l, dir := setup(t)
|
||||
run(t, c, l, Options{Collection: "ik", Roots: []string{dir}})
|
||||
old := g.docs["politika/izin.md"].Digest
|
||||
write(t, dir, "politika/izin.md", "# İzin\n\nOtuz gün.\n")
|
||||
// A changed time is what makes it look again; some filesystems keep
|
||||
// seconds only.
|
||||
later := time.Now().Add(2 * time.Second)
|
||||
_ = os.Chtimes(filepath.Join(dir, "politika", "izin.md"), later, later)
|
||||
rep, k := run(t, c, l, Options{Collection: "ik", Roots: []string{dir}})
|
||||
if rep.Sent != 1 || k["sent"][0] != "politika/izin.md" || g.docs["politika/izin.md"].Digest == old {
|
||||
t.Errorf("after a change %+v %v", rep, k)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRemovesOnlyWhatThisMachineSentUnderTheseRoots(t *testing.T) {
|
||||
g, c, l, dir := setup(t)
|
||||
run(t, c, l, Options{Collection: "ik", Roots: []string{dir}})
|
||||
// Somebody else's document, sent from another machine.
|
||||
g.docs["baska/makine.md"] = RemoteDoc{Name: "baska/makine.md", Digest: "x"}
|
||||
// A file of this machine from another folder.
|
||||
other := t.TempDir()
|
||||
write(t, other, "not.md", "başka klasör")
|
||||
run(t, c, l, Options{Collection: "ik", Roots: []string{other}})
|
||||
|
||||
if err := os.Remove(filepath.Join(dir, "politika", "masraf.txt")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rep, k := run(t, c, l, Options{Collection: "ik", Roots: []string{dir}, Delete: true})
|
||||
if rep.Deleted != 1 || k["deleted"][0] != "politika/masraf.txt" {
|
||||
t.Errorf("deleted %+v %v", rep, k)
|
||||
}
|
||||
for _, keep := range []string{"baska/makine.md", "not.md", "politika/izin.md"} {
|
||||
if _, ok := g.docs[keep]; !ok {
|
||||
t.Errorf("%s was deleted", keep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestADryRunSendsAndRecordsNothing(t *testing.T) {
|
||||
g, c, l, dir := setup(t)
|
||||
rep, k := run(t, c, l, Options{Collection: "ik", Roots: []string{dir}, DryRun: true})
|
||||
if rep.Sent != 2 || len(k["would-send"]) != 2 || rep.Bytes == 0 || rep.Chunks != 2 {
|
||||
t.Errorf("dry run %+v %v", rep, k)
|
||||
}
|
||||
if g.puts != 0 || g.embedded != 0 {
|
||||
t.Errorf("a dry run sent %d documents, embedded %d chunks", g.puts, g.embedded)
|
||||
}
|
||||
if es, _ := l.Entries(context.Background(), c.Base, "kb_x"); len(es) != 0 {
|
||||
t.Errorf("a dry run wrote the ledger: %+v", es)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAReadOnlyCollectionIsRefusedAndALoadingModelWaitedFor(t *testing.T) {
|
||||
g, c, l, dir := setup(t)
|
||||
g.writable = false
|
||||
if _, err := Sync(context.Background(), c, l, Options{Collection: "ik", Roots: []string{dir}}, nil); err == nil ||
|
||||
!strings.Contains(err.Error(), "not add") {
|
||||
t.Errorf("read-only gave %v", err)
|
||||
}
|
||||
g.writable, g.busyFirst = true, true
|
||||
var waited []string
|
||||
c.Waiting = func(why string, _ time.Duration) { waited = append(waited, why) }
|
||||
rep, _ := run(t, c, l, Options{Collection: "ik", Roots: []string{dir}, Workers: 1})
|
||||
if rep.Sent != 2 || len(waited) != 1 || !strings.Contains(waited[0], "loading") {
|
||||
t.Errorf("after a 503 %+v, waited %v", rep, waited)
|
||||
}
|
||||
if _, err := Sync(context.Background(), c, l, Options{Collection: "yok", Roots: []string{dir}}, nil); err == nil {
|
||||
t.Error("a collection that is not there was synced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFiltersAndNames(t *testing.T) {
|
||||
_, c, l, dir := setup(t)
|
||||
write(t, dir, "rapor/2026/ocak.md", "ocak")
|
||||
write(t, dir, "rapor/taslak.md", "taslak")
|
||||
rep, k := run(t, c, l, Options{Collection: "ik", Roots: []string{dir}, DryRun: true,
|
||||
Include: []string{"rapor/**/*.md"}, Exclude: []string{"taslak*"}})
|
||||
if rep.Sent != 1 || k["would-send"][0] != "rapor/2026/ocak.md" {
|
||||
t.Errorf("filtered %+v %v", rep, k)
|
||||
}
|
||||
// Two roots keep their names apart.
|
||||
other := t.TempDir()
|
||||
write(t, other, "politika/izin.md", "aynı ad")
|
||||
_, k = run(t, c, l, Options{Collection: "ik", Roots: []string{dir, other}, DryRun: true, Include: []string{"izin.md"}})
|
||||
if len(k["would-send"]) != 2 || k["would-send"][0] == k["would-send"][1] {
|
||||
t.Errorf("two roots: %v", k["would-send"])
|
||||
}
|
||||
if _, err := Sync(context.Background(), c, l, Options{Collection: "ik", Roots: []string{t.TempDir()}}, nil); !errors.Is(err, ErrNothing) {
|
||||
t.Errorf("an empty folder gave %v", err)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -37,7 +38,9 @@ func (a *API) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /v1/models", a.fetch)
|
||||
mux.HandleFunc("GET /v1/models/{model}/{version}", a.status)
|
||||
mux.HandleFunc("DELETE /v1/models/{model}/{version}", a.remove)
|
||||
mux.HandleFunc("PUT /v1/models/{model}/{version}/{file}", a.upload)
|
||||
mux.HandleFunc("GET /v1/health", a.health)
|
||||
mux.HandleFunc("PUT /v1/firewall", a.putFirewall)
|
||||
return mux
|
||||
}
|
||||
|
||||
@@ -199,3 +202,59 @@ func LastPathElement(u string) string {
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// maxUpload bounds a file sent to the mirror rather than fetched by it.
|
||||
const maxUpload = 8 << 30
|
||||
|
||||
// upload keeps a file the caller sends — an adapter a node trained, which
|
||||
// is on no registry to fetch from — with the same discipline as an import:
|
||||
// its checksum and source beside it, and an existing version never
|
||||
// replaced. X-Llmgw-Source says where it came from.
|
||||
func (a *API) upload(w http.ResponseWriter, r *http.Request) {
|
||||
e := Entry{Model: r.PathValue("model"), Version: r.PathValue("version"), File: r.PathValue("file")}
|
||||
for _, n := range []string{e.Model, e.Version, e.File} {
|
||||
if err := ValidName(n); err != nil {
|
||||
fail(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(e.Path(a.m.root)); err == nil {
|
||||
fail(w, http.StatusConflict, fmt.Errorf("%s/%s/%s: %w", e.Model, e.Version, e.File, ErrExists))
|
||||
return
|
||||
}
|
||||
tmp, err := os.CreateTemp(a.m.root, ".upload-*")
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
n, err := io.Copy(tmp, http.MaxBytesReader(w, r.Body, maxUpload))
|
||||
if cerr := tmp.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, fmt.Errorf("receiving the file: %w", err))
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
fail(w, http.StatusBadRequest, errors.New("the file is empty"))
|
||||
return
|
||||
}
|
||||
source := strings.TrimSpace(r.Header.Get("X-Llmgw-Source"))
|
||||
if source == "" {
|
||||
source = "uploaded"
|
||||
}
|
||||
added, err := Import(r.Context(), a.m.root, tmp.Name(), e, "", source, nil)
|
||||
switch {
|
||||
case errors.Is(err, ErrExists):
|
||||
fail(w, http.StatusConflict, err)
|
||||
return
|
||||
case err != nil:
|
||||
fail(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, FetchResponse{
|
||||
Status: Status{Model: e.Model, Version: e.Version, File: e.File, State: Present, BytesDone: added.Entry.Bytes, BytesTotal: added.Entry.Bytes},
|
||||
URL: e.URL(a.base),
|
||||
})
|
||||
}
|
||||
@@ -100,6 +100,36 @@ func (c *Client) Fetch(ctx context.Context, req FetchRequest) (FetchResponse, er
|
||||
return out, err
|
||||
}
|
||||
|
||||
// Upload sends a file for the mirror to keep at model/version/file, with
|
||||
// where it came from; it answers the URL nodes fetch it from.
|
||||
func (c *Client) Upload(ctx context.Context, model, version, file string, body []byte, source string) (FetchResponse, error) {
|
||||
var out FetchResponse
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut,
|
||||
c.base+"/v1/models/"+url.PathEscape(model)+"/"+url.PathEscape(version)+"/"+url.PathEscape(file), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
req.Header.Set("X-Llmgw-Source", source)
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("reaching the mirror at %s: %w", c.base, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
var e struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &e)
|
||||
return out, &APIError{Status: resp.StatusCode, Msg: e.Error}
|
||||
}
|
||||
return out, json.Unmarshal(raw, &out)
|
||||
}
|
||||
|
||||
// Status is where one model version stands.
|
||||
func (c *Client) Status(ctx context.Context, model, version string) (FetchResponse, error) {
|
||||
var out FetchResponse
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package mirror
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// The mirror's machine keeps llmgw's firewall as the cluster's settings
|
||||
// say, as every other machine does. The mirror runs unprivileged and cannot
|
||||
// change the ruleset: the control plane tells it what the settings say, it
|
||||
// writes that beside its models, and a root path unit on the machine
|
||||
// applies it with `llmgw firewall sync`.
|
||||
|
||||
// FirewallState is what the cluster's settings say of a machine's firewall.
|
||||
type FirewallState struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
// Networks are let in besides those the machine reaches others on.
|
||||
Networks []string `json:"networks,omitempty"`
|
||||
// Toward are the cluster's machines, by address: each one's network is
|
||||
// let in, as the machine's route to it says.
|
||||
Toward []string `json:"toward,omitempty"`
|
||||
}
|
||||
|
||||
// FirewallFile is where, in the mirror's directory, the state is written.
|
||||
// Hidden, so it is never taken for a model.
|
||||
const FirewallFile = ".firewall.json"
|
||||
|
||||
func (a *API) putFirewall(w http.ResponseWriter, r *http.Request) {
|
||||
var st FirewallState
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&st); err != nil {
|
||||
fail(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
b, _ := json.Marshal(st)
|
||||
p := filepath.Join(a.m.root, FirewallFile)
|
||||
changed := false
|
||||
if old, err := os.ReadFile(p); err != nil || !bytes.Equal(old, b) {
|
||||
// Renamed into place: the path unit sees one change, of a whole file.
|
||||
tmp := p + ".new"
|
||||
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
||||
fail(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if err := os.Rename(tmp, p); err != nil {
|
||||
fail(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"changed": changed})
|
||||
}
|
||||
|
||||
// SetFirewall tells the mirror what the cluster's settings say of its
|
||||
// machine's firewall; it answers whether that changed anything.
|
||||
func (c *Client) SetFirewall(ctx context.Context, st FirewallState) (bool, error) {
|
||||
var out struct {
|
||||
Changed bool `json:"changed"`
|
||||
}
|
||||
err := c.do(ctx, http.MethodPut, "/v1/firewall", st, &out)
|
||||
return out.Changed, err
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package mirror
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf"
|
||||
)
|
||||
|
||||
func TestTheMirrorKeepsWhatTheSettingsSayOfItsFirewall(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
srv := httptest.NewServer(NewAPI(NewManager(root, discardLog()), "http://m:8081").Handler())
|
||||
defer srv.Close()
|
||||
c, err := NewClient(srv.URL, tlsconf.Config{}, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
st := FirewallState{Enabled: true, Networks: []string{"192.168.5.0/24"}, Toward: []string{"10.0.0.147"}}
|
||||
if changed, err := c.SetFirewall(ctx, st); err != nil || !changed {
|
||||
t.Fatalf("first: %v %v", changed, err)
|
||||
}
|
||||
if changed, err := c.SetFirewall(ctx, st); err != nil || changed {
|
||||
t.Errorf("the same again: %v %v", changed, err)
|
||||
}
|
||||
var back FirewallState
|
||||
b, _ := os.ReadFile(filepath.Join(root, FirewallFile))
|
||||
if err := json.Unmarshal(b, &back); err != nil || !back.Enabled || back.Toward[0] != "10.0.0.147" {
|
||||
t.Errorf("kept %s", b)
|
||||
}
|
||||
if all, err := c.List(ctx); err != nil || len(all.Models) != 0 {
|
||||
t.Errorf("the file is taken for a model: %+v %v", all, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package mirror
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf"
|
||||
)
|
||||
|
||||
func TestAFileSentToTheMirrorIsKeptWithItsChecksumAndNeverReplaced(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
srv := httptest.NewServer(NewAPI(NewManager(root, nil), "http://mirror:8081").Handler())
|
||||
defer srv.Close()
|
||||
c, err := NewClient(srv.URL, tlsconf.Config{}, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
got, err := c.Upload(ctx, "politika-tonu", "train-1", "politika-tonu.gguf", []byte("GGUF adapter"), "trained by job train-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.URL != "http://mirror:8081/politika-tonu/train-1/politika-tonu.gguf" || got.Status.BytesTotal != 12 {
|
||||
t.Errorf("answered %+v", got)
|
||||
}
|
||||
e := Entry{Model: "politika-tonu", Version: "train-1", File: "politika-tonu.gguf"}
|
||||
if b, _ := os.ReadFile(e.Path(root)); string(b) != "GGUF adapter" {
|
||||
t.Errorf("kept %q", b)
|
||||
}
|
||||
if b, _ := os.ReadFile(sourcePath(e.Path(root))); !strings.Contains(string(b), "train-1") {
|
||||
t.Errorf("source %q", b)
|
||||
}
|
||||
if _, err := os.Stat(checksumPath(e.Path(root))); err != nil {
|
||||
t.Errorf("no checksum: %v", err)
|
||||
}
|
||||
if _, err := c.Upload(ctx, "politika-tonu", "train-1", "politika-tonu.gguf", []byte("other"), "x"); err == nil {
|
||||
t.Error("an existing version was replaced")
|
||||
}
|
||||
if _, err := c.Upload(ctx, "../etc", "v", "f", []byte("x"), "x"); err == nil {
|
||||
t.Error("a path outside the mirror was taken")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package pgha
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PostgreSQL's own defaults — 128MB of cache, 4MB to sort in — are for a
|
||||
// machine it barely uses. A knowledge base's vector index is searched well
|
||||
// only from memory: one that does not fit is read from disk on every search.
|
||||
// So each member is sized to the memory it has, read when setup runs, and
|
||||
// again on every later run, so a machine given more memory gets the use of it.
|
||||
|
||||
// MemorySettings are PostgreSQL's memory parameters for a machine with total
|
||||
// bytes of memory. shared is a machine that also runs a control plane or an
|
||||
// agent: the database is given less of it, since a gateway swapped out to
|
||||
// give the database cache is worse than a smaller cache.
|
||||
func MemorySettings(total int64, shared bool) map[string]string {
|
||||
const mb = 1 << 20
|
||||
ram := total / mb
|
||||
// Shares of memory, in MB: the cache PostgreSQL keeps itself, what the
|
||||
// planner may count on the kernel caching besides, what building or
|
||||
// vacuuming an index may take, and what one sort may.
|
||||
buffers, cache, maint, sort := ram/4, ram*3/4, ram/16, ram/256
|
||||
if shared {
|
||||
buffers, cache, maint, sort = ram*3/20, ram/2, ram/32, ram/512
|
||||
}
|
||||
clamp := func(v, lo, hi int64) int64 { return min(max(v, lo), hi) }
|
||||
return map[string]string{
|
||||
"shared_buffers": strconv.FormatInt(clamp(buffers, 96, 32<<10), 10) + "MB",
|
||||
"effective_cache_size": strconv.FormatInt(clamp(cache, 256, 1<<20), 10) + "MB",
|
||||
"maintenance_work_mem": strconv.FormatInt(clamp(maint, 64, 2<<10), 10) + "MB",
|
||||
"work_mem": strconv.FormatInt(clamp(sort, 4, 64), 10) + "MB",
|
||||
}
|
||||
}
|
||||
|
||||
// MemTotal reads the machine's memory, in bytes, from /proc/meminfo.
|
||||
func MemTotal(meminfo string) (int64, bool) {
|
||||
for _, line := range strings.Split(meminfo, "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) >= 2 && f[0] == "MemTotal:" {
|
||||
kb, err := strconv.ParseInt(f[1], 10, 64)
|
||||
return kb << 10, err == nil && kb > 0
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// memory sizes each member to its machine, in its own configuration — the
|
||||
// machines need not be alike. What takes effect on reload is applied so;
|
||||
// shared_buffers needs a restart, given one member at a time, the leader
|
||||
// last, each waited on before the next.
|
||||
func (s *Setup) memory(ctx context.Context, sec Secrets) error {
|
||||
var restart []Node
|
||||
for _, d := range s.DB {
|
||||
out, err := d.Remote.Run(ctx, `cat /proc/meminfo
|
||||
if [ -f /etc/llmgw/server.yaml ] || [ -f /etc/llmgw/agent.yaml ]; then echo "llmgw-shared"; fi`, nil)
|
||||
total, ok := MemTotal(string(out))
|
||||
if err != nil || !ok {
|
||||
s.emit("memory", false, "%s: could not read its memory; PostgreSQL left as it is", d.Name)
|
||||
continue
|
||||
}
|
||||
shared := strings.Contains(string(out), "llmgw-shared")
|
||||
params := MemorySettings(total, shared)
|
||||
body, _ := json.Marshal(params)
|
||||
out, err = d.Remote.Run(ctx, `set -e
|
||||
test -f /etc/patroni/config.yml || { echo missing; exit 0; }
|
||||
want=$(cat)
|
||||
r=$(sudo env WANT="$want" python3 - <<'PY'
|
||||
import json, os, yaml
|
||||
p = "/etc/patroni/config.yml"
|
||||
want = json.loads(os.environ["WANT"])
|
||||
c = yaml.safe_load(open(p))
|
||||
pg = c.setdefault("postgresql", {})
|
||||
params = pg.get("parameters") or {}
|
||||
pg["parameters"] = params
|
||||
if all(str(params.get(k)) == v for k, v in want.items()):
|
||||
print("same")
|
||||
else:
|
||||
restart = str(params.get("shared_buffers")) != want["shared_buffers"]
|
||||
params.update(want)
|
||||
open(p + ".new", "w").write("# llmgw: the control planes' store, kept by Patroni, its leader lock in etcd.\n" +
|
||||
yaml.safe_dump(c, default_flow_style=False, sort_keys=False))
|
||||
print("restart" if restart else "changed")
|
||||
PY
|
||||
)
|
||||
if [ "$r" != same ]; then
|
||||
sudo chown root:postgres /etc/patroni/config.yml.new
|
||||
sudo chmod 640 /etc/patroni/config.yml.new
|
||||
sudo mv -f /etc/patroni/config.yml.new /etc/patroni/config.yml
|
||||
sudo systemctl reload patroni
|
||||
fi
|
||||
echo "$r"`, body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("on %s: %v: %s", d.Name, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
what := fmt.Sprintf("%s: %d MB of memory%s; shared_buffers %s, effective_cache_size %s, maintenance_work_mem %s, work_mem %s",
|
||||
d.Name, total>>20, map[bool]string{true: ", shared with llmgw"}[shared],
|
||||
params["shared_buffers"], params["effective_cache_size"], params["maintenance_work_mem"], params["work_mem"])
|
||||
switch r := strings.TrimSpace(lastLine(out)); r {
|
||||
case "same", "missing":
|
||||
s.emit("memory", false, "%s", what)
|
||||
case "restart":
|
||||
s.emit("memory", true, "%s", what)
|
||||
restart = append(restart, d)
|
||||
default:
|
||||
s.emit("memory", true, "%s", what)
|
||||
}
|
||||
}
|
||||
// Patroni takes a reload in its own time: restarted before it has written
|
||||
// the new configuration, PostgreSQL comes back on the old one. So each is
|
||||
// waited on until Patroni says a restart is pending — and a member found
|
||||
// pending, by a run that stopped before restarting it, is restarted too.
|
||||
pending := func(c Cluster, name string) bool {
|
||||
for _, m := range c.Members {
|
||||
if m.Name == name {
|
||||
return m.PendingRestart
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
var c Cluster
|
||||
err := s.waitFor(ctx, s.dbHosts(), func(got Cluster) bool {
|
||||
c = got
|
||||
for _, d := range restart {
|
||||
if !pending(got, d.Name) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("waiting for Patroni to take the new configuration: %w", err)
|
||||
}
|
||||
for _, d := range s.DB {
|
||||
if pending(c, d.Name) && !slices.ContainsFunc(restart, func(n Node) bool { return n.Name == d.Name }) {
|
||||
restart = append(restart, d)
|
||||
}
|
||||
}
|
||||
if len(restart) == 0 {
|
||||
return nil
|
||||
}
|
||||
// The leader last: its restart is the only one the control planes see,
|
||||
// a few seconds without writes, and by then the rest are back.
|
||||
slices.SortStableFunc(restart, func(a, b Node) int {
|
||||
al, bl := a.Name == c.Leader(), b.Name == c.Leader()
|
||||
switch {
|
||||
case al && !bl:
|
||||
return 1
|
||||
case bl && !al:
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
for _, d := range restart {
|
||||
if _, err := restJSON(ctx, d.IP, http.MethodPost, "/restart", []byte(`{"restart_pending": true}`), sec.RestAPI, 2*time.Minute); err != nil {
|
||||
return fmt.Errorf("restarting PostgreSQL on %s for its new shared_buffers: %w", d.Name, err)
|
||||
}
|
||||
if err := s.waitFor(ctx, s.dbHosts(), func(c Cluster) bool { return c.Complete(len(s.DB)) }); err != nil {
|
||||
return fmt.Errorf("after restarting %s: %w", d.Name, err)
|
||||
}
|
||||
s.emit("memory", true, "%s: PostgreSQL restarted with the new shared_buffers", d.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -159,6 +159,11 @@ func TestARunningClusterIsLeftAsItIs(t *testing.T) {
|
||||
if !a.ranAny("CREATE ROLE") {
|
||||
t.Error("the role was not made sure of on the leader")
|
||||
}
|
||||
// Enabled where the package is there, which on a running cluster is
|
||||
// all a rerun does about it: it installs nothing.
|
||||
if !a.ranAny("CREATE EXTENSION IF NOT EXISTS vector") {
|
||||
t.Error("pgvector was not enabled in llmgw's database on the leader")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoDatabaseMachinesWithoutAThirdVoteAreRefused(t *testing.T) {
|
||||
@@ -460,3 +465,97 @@ func TestAControlPlaneWithoutTheAuthorityConnectsByTLSOnceTheClusterHasIt(t *tes
|
||||
t.Errorf("TLS asked of a cluster without it: %s", dsn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgreSQLIsGivenAShareOfTheMachinesMemory(t *testing.T) {
|
||||
total, ok := MemTotal("MemTotal: 4009848 kB\nMemFree: 976000 kB\n")
|
||||
if !ok || total != 4009848<<10 {
|
||||
t.Fatalf("MemTotal %d %v", total, ok)
|
||||
}
|
||||
own := MemorySettings(total, false)
|
||||
if own["shared_buffers"] != "978MB" || own["effective_cache_size"] != "2936MB" ||
|
||||
own["maintenance_work_mem"] != "244MB" || own["work_mem"] != "15MB" {
|
||||
t.Errorf("a machine of its own: %v", own)
|
||||
}
|
||||
// Beside a control plane, less of it.
|
||||
if sh := MemorySettings(total, true); sh["shared_buffers"] != "587MB" || sh["work_mem"] != "7MB" {
|
||||
t.Errorf("a shared machine: %v", sh)
|
||||
}
|
||||
// Never below what PostgreSQL needs, never past what it can use.
|
||||
if tiny := MemorySettings(512<<20, true); tiny["shared_buffers"] != "96MB" || tiny["work_mem"] != "4MB" {
|
||||
t.Errorf("a small machine: %v", tiny)
|
||||
}
|
||||
if big := MemorySettings(1<<40, false); big["shared_buffers"] != "32768MB" || big["maintenance_work_mem"] != "2048MB" {
|
||||
t.Errorf("a large machine: %v", big)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSharedBuffersRestartTheReplicasBeforeTheLeader(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var restarted []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost && r.URL.Path == "/restart" {
|
||||
if _, pass, _ := r.BasicAuth(); pass != "pw" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
host, _, _ := strings.Cut(r.Host, ":")
|
||||
mu.Lock()
|
||||
restarted = append(restarted, host)
|
||||
mu.Unlock()
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
u, _ := url.Parse(srv.URL)
|
||||
old := restPort
|
||||
restPort, _ = strconv.Atoi(u.Port())
|
||||
defer func() { restPort = old }()
|
||||
|
||||
answers := map[string]string{"cat /proc/meminfo": "MemTotal: 4009848 kB\n", "WANT": "restart\n"}
|
||||
leader := &machine{name: "a", answers: answers}
|
||||
replica := &machine{name: "b", answers: answers}
|
||||
up := Cluster{Members: []MemberState{{Name: "a", Role: "leader", State: "running", PendingRestart: true},
|
||||
{Name: "b", Role: "replica", State: "streaming", PendingRestart: true}}}
|
||||
s := Setup{
|
||||
DB: []Node{{Member{"a", "127.0.0.1"}, leader}, {Member{"b", "localhost"}, replica}},
|
||||
Status: func(context.Context, []string) (Cluster, error) { return up, nil },
|
||||
Wait: time.Second,
|
||||
}
|
||||
if err := s.memory(context.Background(), Secrets{RestAPI: "pw"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(restarted, " ") != "localhost 127.0.0.1" {
|
||||
t.Errorf("restarted %v; the replica first, then the leader", restarted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMemberLeftPendingARestartIsRestartedOnTheNextRun(t *testing.T) {
|
||||
// A run that stopped after writing the configuration, or restarted before
|
||||
// Patroni had taken it: nothing to change, but PostgreSQL still on the old.
|
||||
var restarted []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/restart" {
|
||||
host, _, _ := strings.Cut(r.Host, ":")
|
||||
restarted = append(restarted, host)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
u, _ := url.Parse(srv.URL)
|
||||
old := restPort
|
||||
restPort, _ = strconv.Atoi(u.Port())
|
||||
defer func() { restPort = old }()
|
||||
|
||||
answers := map[string]string{"cat /proc/meminfo": "MemTotal: 4009848 kB\n", "WANT": "same\n"}
|
||||
up := Cluster{Members: []MemberState{{Name: "a", Role: "leader", State: "running"},
|
||||
{Name: "b", Role: "replica", State: "streaming", PendingRestart: true}}}
|
||||
s := Setup{
|
||||
DB: []Node{{Member{"a", "127.0.0.1"}, &machine{answers: answers}}, {Member{"b", "localhost"}, &machine{answers: answers}}},
|
||||
Status: func(context.Context, []string) (Cluster, error) { return up, nil },
|
||||
Wait: time.Second,
|
||||
}
|
||||
if err := s.memory(context.Background(), Secrets{RestAPI: "pw"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(restarted, " ") != "localhost" {
|
||||
t.Errorf("restarted %v; only the member pending", restarted)
|
||||
}
|
||||
}
|
||||
+20
-1
@@ -128,6 +128,9 @@ func (s *Setup) Run(ctx context.Context) (string, error) {
|
||||
if err := s.ensurePatroniTLS(ctx, sec); err != nil {
|
||||
return "", fmt.Errorf("patroni api: %w", err)
|
||||
}
|
||||
if err := s.memory(ctx, sec); err != nil {
|
||||
return "", fmt.Errorf("memory: %w", err)
|
||||
}
|
||||
if err := s.database(ctx); err != nil {
|
||||
return "", fmt.Errorf("database: %w", err)
|
||||
}
|
||||
@@ -502,6 +505,14 @@ if [ -n "$need" ]; then
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get -qq install -y $need >/dev/null
|
||||
fi
|
||||
v=$(ls /usr/lib/postgresql | sort -n | tail -1)
|
||||
# pgvector, for knowledge bases: on every member, since any of them may lead.
|
||||
# Not a reason to stop; the store says knowledge bases are off without it.
|
||||
if [ ! -f /usr/share/postgresql/$v/extension/vector.control ]; then
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get -qq install -y postgresql-$v-pgvector >/dev/null 2>&1 ||
|
||||
{ sudo DEBIAN_FRONTEND=noninteractive apt-get -qq update >/dev/null 2>&1 &&
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get -qq install -y postgresql-$v-pgvector >/dev/null 2>&1; } ||
|
||||
echo "no-pgvector"
|
||||
fi
|
||||
if [ -d /var/lib/postgresql/$v/main ]; then
|
||||
if sudo systemctl is-active --quiet postgresql@$v-main; then
|
||||
n=$(sudo -u postgres psql -Atc "select count(*) from pg_database where datname not in ('postgres','template0','template1')")
|
||||
@@ -553,13 +564,21 @@ sudo -u postgres psql -q -v ON_ERROR_STOP=1 -v pw="$pw" <<'SQL'
|
||||
SELECT format('CREATE ROLE %I LOGIN', 'llmgw') WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'llmgw') \gexec
|
||||
ALTER ROLE llmgw PASSWORD :'pw';
|
||||
SELECT format('CREATE DATABASE %I OWNER %I', 'llmgw', 'llmgw') WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'llmgw') \gexec
|
||||
SQL`, nil)
|
||||
SQL
|
||||
sudo -u postgres psql -q -d llmgw -v ON_ERROR_STOP=1 <<'SQL' || echo "pgvector-failed"
|
||||
SELECT 'CREATE EXTENSION IF NOT EXISTS vector' WHERE EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'vector') \gexec
|
||||
SQL
|
||||
sudo -u postgres psql -d llmgw -Atc "select 'vector=' || count(*) from pg_extension where extname = 'vector'"`, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("on %s: %v: %s", leader.Name, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
// Said as a change only when something was made: a rerun that finds
|
||||
// both is not a reason to restart the gateway.
|
||||
s.emit("database", !strings.Contains(string(out), "had=2"), "role and database %s on %s", Database, leader.Name)
|
||||
if !strings.Contains(string(out), "vector=1") {
|
||||
s.emit("pgvector", false, "pgvector is not enabled in %s, so knowledge bases stay off "+
|
||||
"(install postgresql-<version>-pgvector on every member and run setup again)", Database)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ type MemberState struct {
|
||||
State string `json:"state"`
|
||||
Host string `json:"host"`
|
||||
Timeline int `json:"timeline"`
|
||||
// PendingRestart is a parameter changed that PostgreSQL takes only on
|
||||
// restart.
|
||||
PendingRestart bool `json:"pending_restart"`
|
||||
// Lag is how far behind the leader a replica is, in bytes; -1 when
|
||||
// Patroni does not know, which is itself worth an alert.
|
||||
Lag int64 `json:"-"`
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/auth"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
func TestAnOwnedAdapterIsSeenOnlyInItsPartOfTheTree(t *testing.T) {
|
||||
e := newFakeEngine(t, "qwen2.5-14b-instruct")
|
||||
rt, owner := pricedRouter(t, e.srv.URL, "tone")
|
||||
ctx := t.Context()
|
||||
key, _ := rt.store.KeyByID(ctx, "key-1")
|
||||
team, _ := rt.store.CreateTeam(ctx, mustOrgOf(t, rt), "other-team")
|
||||
other, _ := rt.store.CreateProject(ctx, team, "other")
|
||||
otherSecret := store.KeyPrefix + strings.Repeat("b", 43)
|
||||
sum := sha256.Sum256([]byte(otherSecret))
|
||||
if err := rt.store.CreateKey(ctx, store.Key{ID: "key-2", Hash: sum[:], Prefix: "bbbb", Name: "o", ProjectID: other, CreatedAt: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rt.storeModels([]catalog.Model{{Name: "qwen2.5-14b-instruct", Aliases: []string{"q14"},
|
||||
Adapters: []catalog.Adapter{{Name: "tone", Source: "/t.gguf", Owner: "project:" + key.ProjectID}}}})
|
||||
h := rt.Handler()
|
||||
ask := func(secret string) int {
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions",
|
||||
strings.NewReader(`{"model":"tone","messages":[{"role":"user","content":"hi"}]}`))
|
||||
if secret != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+secret)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec.Code
|
||||
}
|
||||
if code := ask(owner); code != http.StatusOK {
|
||||
t.Errorf("its own project: %d", code)
|
||||
}
|
||||
if code := ask(otherSecret); code != http.StatusNotFound {
|
||||
t.Errorf("another project: %d", code)
|
||||
}
|
||||
list := func(secret string) string {
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+secret)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec.Body.String()
|
||||
}
|
||||
if !strings.Contains(list(owner), `"tone"`) || strings.Contains(list(otherSecret), `"tone"`) {
|
||||
t.Errorf("listed: own %s\nother %s", list(owner), list(otherSecret))
|
||||
}
|
||||
// No identity, no owned adapter.
|
||||
if rt.adapterHidden(auth.Principal{}, false, "tone") != true {
|
||||
t.Error("an owned adapter was shown to a caller with no identity")
|
||||
}
|
||||
}
|
||||
@@ -251,6 +251,11 @@ func reusable(req *parsedRequest, anyAnswer bool) bool {
|
||||
if req.noStore || req.ThreadID != "" || req.ContentType != "" || !cacheEndpoints[req.Path] {
|
||||
return false
|
||||
}
|
||||
// An answer from knowledge bases is the caller's own: two keys of one
|
||||
// project can read different collections.
|
||||
if req.knowledge || req.Header.Get(knowledgeHeader) != "" {
|
||||
return false
|
||||
}
|
||||
if v := strings.ToLower(strings.TrimSpace(req.Header.Get(cacheHeader))); v == "no" || v == "off" || v == "bypass" {
|
||||
return false
|
||||
}
|
||||
|
||||
Loaded 100 of 154 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user