Skip to content

Commit 678a7a3

Browse files
authored
Engine: local mode + all-in-one Docker image + BYOK + UI + source endpoint + ingest race fix (#45)
* engine: zero-config local mode (engine --local / VLE_LOCAL_MODE) on :7654 Adds a local mode that moves the base config to zero-setup defaults BEFORE the file/env layers (which still override): listen on :7654, a localhost Postgres URL matching the bundled/dev database, local file storage, and the Postgres-backed river queue (no Redis needed). The engine then boots with no required configuration — closing the 'database.url is required' gap that otherwise blocks a bare run. - --local flag sets VLE_LOCAL_MODE=true so CLI + Docker (env) share one path. - Adds VLE_STORAGE_LOCAL_ROOT binding for the image's data volume. - cmd/engine is already unauthenticated (single tenant), so local-mode auth needs no extra wiring; documented as dev/local only. - Documented in config.example.yaml; tests cover defaults, truthy forms, env-override precedence, and the non-local missing-DB-URL failure. Foundation for the all-in-one image (HAL-185) + local dashboard (HAL-188). Closes HAL-186. * docs: align documented local Postgres URL with the real default (?sslmode=disable) Per CodeRabbit review on #42 — the example referenced the DSN without the sslmode param that applyLocalDefaults actually injects. * engine: fsync source writes + retry source fetch on ErrNotFound (HAL-319) The ingest worker could race the just-written source object (Local.Put did no fsync; River picks the job up within microseconds), failing with 'parse: fetch source: storage: object not found' and marking the doc failed. Local.Put now fsyncs before returning and the source fetch retries on ErrNotFound with short backoff. * config: document that anthropic base_url must include /v1 for GLM/Z.ai gateways (HAL-318) * engine: add GET /v1/documents/{id}/source + all-in-one Docker image (HAL-188, HAL-185) New GET /v1/documents/{id}/source streams the original bytes (for PDF page previews in clients). Dockerfile.allinone bundles the engine + Postgres + the local web UI into one image; docker-allinone.yml publishes it to Docker Hub + GHCR. * ci: enforce LF for shell scripts (protect the Docker entrypoint) * ci: build all-in-one image from the local-mode branch to publish :latest * engine+ui: BYOK — per-request LLM key via X-LLM-* headers + dashboard settings (HAL-188) The engine now boots without a provider key in local mode and accepts per-request credentials (X-LLM-Api-Key / X-LLM-Provider / X-LLM-Base-Url / X-LLM-Model), building a per-request client that drives both the treewalk loop and citation span extraction. The bundled UI gains a settings modal that stores the key in the browser and sends it as headers — so a docker-run user configures their key from the dashboard, not only via env. * ci: trigger all-in-one image build on main + dispatch (post-merge)
1 parent 7a21b11 commit 678a7a3

14 files changed

Lines changed: 1113 additions & 15 deletions

File tree

.gitattributes

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Shell scripts and the all-in-one entrypoint MUST stay LF — CRLF breaks the
2+
# shebang inside the Linux container ("bad interpreter: /usr/bin/env bash^M").
3+
*.sh text eol=lf
4+
deploy/allinone/entrypoint.sh text eol=lf
5+
localapp/serve.py text eol=lf
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
name: docker-allinone
2+
3+
# Build and publish the ALL-IN-ONE image (engine + bundled Postgres + web UI)
4+
# so anyone can `docker run` Vectorless with just an LLM key.
5+
#
6+
# Publishes to Docker Hub AND GitHub Container Registry:
7+
# docker.io/<DOCKERHUB_USERNAME>/vectorless:latest|sha-<short>|vX.Y.Z
8+
# ghcr.io/hallelx2/vectorless:latest|sha-<short>|vX.Y.Z
9+
#
10+
# Requires two repo secrets for the Docker Hub push:
11+
# DOCKERHUB_USERNAME — your Docker Hub account/namespace
12+
# DOCKERHUB_TOKEN — a Docker Hub access token with Read/Write/Delete scope
13+
# (GHCR uses the built-in GITHUB_TOKEN — no extra secret.)
14+
15+
on:
16+
workflow_dispatch: {} # run on demand from the Actions tab / gh CLI
17+
push:
18+
branches: [main] # publish :latest on every push to the default branch
19+
tags: ["v*.*.*"]
20+
21+
permissions:
22+
contents: read
23+
packages: write # push to ghcr.io
24+
25+
jobs:
26+
publish:
27+
name: build + push all-in-one
28+
runs-on: ubuntu-latest
29+
steps:
30+
- name: Checkout
31+
uses: actions/checkout@v4
32+
33+
- name: Set up Docker Buildx
34+
uses: docker/setup-buildx-action@v3
35+
36+
- name: Log in to Docker Hub
37+
uses: docker/login-action@v3
38+
with:
39+
username: ${{ secrets.DOCKERHUB_USERNAME }}
40+
password: ${{ secrets.DOCKERHUB_TOKEN }}
41+
42+
- name: Log in to ghcr.io
43+
uses: docker/login-action@v3
44+
with:
45+
registry: ghcr.io
46+
username: ${{ github.actor }}
47+
password: ${{ secrets.GITHUB_TOKEN }}
48+
49+
- name: Extract tags + labels
50+
id: meta
51+
uses: docker/metadata-action@v5
52+
with:
53+
images: |
54+
docker.io/${{ secrets.DOCKERHUB_USERNAME }}/vectorless
55+
ghcr.io/${{ github.repository_owner }}/vectorless
56+
tags: |
57+
type=raw,value=latest,enable={{is_default_branch}}
58+
type=raw,value=latest,enable=${{ github.event_name == 'workflow_dispatch' }}
59+
type=ref,event=tag
60+
type=sha,prefix=sha-,format=short
61+
62+
- name: Build + push
63+
uses: docker/build-push-action@v6
64+
with:
65+
context: .
66+
file: ./Dockerfile.allinone
67+
platforms: linux/amd64
68+
push: true
69+
tags: ${{ steps.meta.outputs.tags }}
70+
labels: ${{ steps.meta.outputs.labels }}
71+
build-args: |
72+
VERSION=${{ github.ref_name }}
73+
cache-from: type=gha
74+
cache-to: type=gha,mode=max

Dockerfile.allinone

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# ── All-in-one image: engine + bundled Postgres + viewer UI ──────────
2+
#
3+
# One `docker run` gives a fully working Vectorless: the retrieval engine,
4+
# a Postgres instance bundled in the same container, and the local web UI.
5+
# The only thing the user supplies is an LLM provider key.
6+
#
7+
# docker run -p 8080:8080 -p 7654:7654 \
8+
# -e VLE_LLM_ANTHROPIC_API_KEY=<your GLM key> \
9+
# hallelx2/vectorless:latest
10+
# # → UI: http://localhost:8080
11+
# # → API: http://localhost:7654
12+
#
13+
# Context: vectorless-engine/ directory.
14+
15+
# ── Build stage ──────────────────────────────────────────────────────
16+
FROM golang:1.25-alpine AS build
17+
RUN apk add --no-cache ca-certificates
18+
WORKDIR /src
19+
COPY go.mod go.sum ./
20+
RUN go mod download
21+
COPY cmd/ ./cmd/
22+
COPY pkg/ ./pkg/
23+
COPY internal/ ./internal/
24+
ARG VERSION=dev
25+
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
26+
go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" \
27+
-o /bin/engine ./cmd/engine
28+
29+
# ── Runtime stage: Postgres base + python + engine + viewer ──────────
30+
FROM postgres:16-bookworm
31+
32+
RUN apt-get update \
33+
&& apt-get install -y --no-install-recommends python3 ca-certificates \
34+
&& rm -rf /var/lib/apt/lists/*
35+
36+
COPY --from=build /bin/engine /usr/local/bin/engine
37+
COPY localapp/ /opt/vectorless-app/
38+
COPY deploy/allinone/entrypoint.sh /usr/local/bin/vl-entrypoint.sh
39+
RUN chmod +x /usr/local/bin/vl-entrypoint.sh
40+
41+
# Bundled Postgres credentials — must match engine --local's expected DSN
42+
# (postgres://vectorless:vectorless@localhost:5432/vectorless).
43+
ENV POSTGRES_USER=vectorless \
44+
POSTGRES_PASSWORD=vectorless \
45+
POSTGRES_DB=vectorless
46+
47+
# Engine defaults: local mode, minimal ingest (fast, queryable in seconds),
48+
# document bytes under /data (mount a volume here to persist), and GLM via
49+
# z.ai's Anthropic-compatible gateway out of the box. Override any of these
50+
# with -e at runtime; the user still supplies VLE_LLM_ANTHROPIC_API_KEY.
51+
ENV VLE_INGEST_MODE=minimal \
52+
VLE_STORAGE_LOCAL_ROOT=/data/documents \
53+
VLE_LLM_DRIVER=anthropic \
54+
VLE_LLM_ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic/v1 \
55+
VLE_LLM_ANTHROPIC_MODEL=glm-4.6 \
56+
VIEWER_PORT=8080 \
57+
ENGINE_URL=http://localhost:7654 \
58+
HOST=0.0.0.0
59+
60+
EXPOSE 8080 7654
61+
VOLUME ["/data", "/var/lib/postgresql/data"]
62+
63+
ENTRYPOINT ["/usr/local/bin/vl-entrypoint.sh"]
64+
65+
LABEL org.opencontainers.image.title="vectorless (all-in-one)"
66+
LABEL org.opencontainers.image.description="Vectorless retrieval engine + bundled Postgres + web UI in one container. Reasoning-based document retrieval — no chunking, no embeddings, no vector DB."
67+
LABEL org.opencontainers.image.source="https://github.com/hallelx2/vectorless-engine"
68+
LABEL org.opencontainers.image.licenses="Apache-2.0"
69+
LABEL org.opencontainers.image.vendor="Vectorless"

cmd/engine/main.go

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,16 @@ func run() error {
105105

106106
llmClient, err := buildLLM(cfg.LLM)
107107
if err != nil {
108-
return fmt.Errorf("init llm: %w", err)
108+
// A missing provider key is non-fatal in local mode: the bundled UI
109+
// (and any caller) can supply credentials per request via X-LLM-*
110+
// headers (BYOK), so boot without a shared client and let those
111+
// requests build their own. Any other init error is still fatal.
112+
if config.LocalModeEnabled() && llmKeyMissing(cfg.LLM) {
113+
logger.Warn("no LLM provider key configured — queries require a per-request key (BYOK via X-LLM-Api-Key), or set VLE_LLM_ANTHROPIC_API_KEY")
114+
llmClient = nil
115+
} else {
116+
return fmt.Errorf("init llm: %w", err)
117+
}
109118
}
110119
strategy := buildStrategy(cfg.Retrieval, llmClient, store)
111120

@@ -231,12 +240,16 @@ func run() error {
231240
// (gated by retrieval.treewalk.enabled), even on a deployment
232241
// using chunked-tree as its default selection path.
233242
var treeWalkStrategy *retrieval.TreeWalkStrategy
234-
if cfg.Retrieval.TreeWalk.Enabled && llmClient != nil {
243+
if cfg.Retrieval.TreeWalk.Enabled {
244+
// Built even when llmClient is nil (no server key): the per-request
245+
// BYOK path sets the strategy's client from X-LLM-Api-Key headers,
246+
// so the endpoint stays available for callers that bring their own key.
235247
treeWalkStrategy = buildTreeWalkStrategy(cfg.Retrieval, llmClient, store)
236248
logger.Info("retrieval: treewalk answer endpoint enabled",
237249
"max_hops", treeWalkStrategy.MaxHops,
238250
"page_content_limit", treeWalkStrategy.PageContentLimit,
239251
"model_override", cfg.Retrieval.TreeWalk.Model,
252+
"server_key", llmClient != nil,
240253
)
241254
}
242255

@@ -260,6 +273,9 @@ func run() error {
260273
Abstain: cfg.Retrieval.Abstain,
261274
TreeWalkStrategy: treeWalkStrategy,
262275
TreeWalk: cfg.Retrieval.TreeWalk,
276+
BuildLLM: func(provider, apiKey, baseURL, model string) (llmgate.Client, error) {
277+
return buildLLMFrom(cfg.LLM, provider, apiKey, baseURL, model)
278+
},
263279
}
264280

265281
srv := &http.Server{
@@ -399,6 +415,66 @@ func buildLLM(c config.LLMConfig) (llmgate.Client, error) {
399415
}
400416
}
401417

418+
// buildLLMFrom constructs an llmgate client from caller-supplied
419+
// credentials (BYOK), inheriting the server's configured provider, base
420+
// URL, and model whenever a field is left empty. This backs the
421+
// per-request X-LLM-* headers so a user of the bundled UI can paste only
422+
// their API key and have everything else default to the engine's config.
423+
// llmKeyMissing reports whether the configured provider has no API key.
424+
// Used to keep local-mode boot non-fatal so per-request BYOK can work.
425+
func llmKeyMissing(c config.LLMConfig) bool {
426+
switch c.Driver {
427+
case "anthropic":
428+
return c.Anthropic.APIKey == ""
429+
case "openai":
430+
return c.OpenAI.APIKey == ""
431+
case "gemini":
432+
return c.Gemini.APIKey == ""
433+
}
434+
return false
435+
}
436+
437+
func buildLLMFrom(c config.LLMConfig, provider, apiKey, baseURL, model string) (llmgate.Client, error) {
438+
if provider == "" {
439+
provider = c.Driver
440+
}
441+
switch provider {
442+
case "anthropic":
443+
if model == "" {
444+
model = c.Anthropic.Model
445+
}
446+
if baseURL == "" {
447+
baseURL = c.Anthropic.BaseURL
448+
}
449+
return anthropic.New(anthropic.Config{
450+
APIKey: apiKey,
451+
Model: model,
452+
ReasoningModel: c.Anthropic.ReasoningModel,
453+
BaseURL: baseURL,
454+
})
455+
case "openai":
456+
if model == "" {
457+
model = c.OpenAI.Model
458+
}
459+
return openai.New(openai.Config{
460+
APIKey: apiKey,
461+
Model: model,
462+
ReasoningModel: c.OpenAI.ReasoningModel,
463+
})
464+
case "gemini":
465+
if model == "" {
466+
model = c.Gemini.Model
467+
}
468+
return gemini.New(gemini.Config{
469+
APIKey: apiKey,
470+
Model: model,
471+
ReasoningModel: c.Gemini.ReasoningModel,
472+
})
473+
default:
474+
return nil, fmt.Errorf("unknown llm provider: %s", provider)
475+
}
476+
}
477+
402478
func buildStrategy(c config.RetrievalConfig, client llmgate.Client, store storage.Storage) retrieval.Strategy {
403479
switch c.Strategy {
404480
case "single-pass":

deploy/allinone/entrypoint.sh

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#!/usr/bin/env bash
2+
# All-in-one entrypoint: Postgres + Vectorless engine + the local viewer UI,
3+
# all in one container. Postgres is bundled so `docker run` needs no external
4+
# services — the user only supplies an LLM provider key.
5+
set -euo pipefail
6+
7+
PGUSER_="${POSTGRES_USER:-vectorless}"
8+
PGDB_="${POSTGRES_DB:-vectorless}"
9+
10+
echo "[vectorless] starting bundled Postgres…"
11+
# The official postgres entrypoint handles first-run initdb (using the
12+
# POSTGRES_* env vars) and then execs postgres. Run it in the background so we
13+
# can start the engine + UI alongside it in the same container.
14+
docker-entrypoint.sh postgres &
15+
16+
echo "[vectorless] waiting for Postgres to accept connections…"
17+
until pg_isready -h localhost -U "$PGUSER_" -d "$PGDB_" >/dev/null 2>&1; do
18+
sleep 1
19+
done
20+
echo "[vectorless] Postgres ready."
21+
22+
# Start the viewer UI (serves the single-page app + same-origin proxy to the
23+
# engine). Backgrounded; the engine is the container's main process.
24+
if [ -f /opt/vectorless-app/serve.py ]; then
25+
echo "[vectorless] starting viewer UI on :${VIEWER_PORT:-8080}${ENGINE_URL:-http://localhost:7654}"
26+
PYTHONIOENCODING=utf-8 python3 /opt/vectorless-app/serve.py &
27+
fi
28+
29+
if [ -z "${VLE_LLM_ANTHROPIC_API_KEY:-}" ] && [ -z "${VLE_LLM_OPENAI_API_KEY:-}" ] && [ -z "${VLE_LLM_GEMINI_API_KEY:-}" ]; then
30+
echo "[vectorless] WARNING: no LLM provider key set. Ingestion will work, but"
31+
echo "[vectorless] queries need e.g. -e VLE_LLM_ANTHROPIC_API_KEY=<your GLM key>"
32+
fi
33+
34+
echo "[vectorless] starting engine (local mode) on :7654 …"
35+
# exec so the engine becomes PID 1's foreground process and receives signals.
36+
exec engine --local

internal/api/server.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,13 @@ type Deps struct {
5959
// LLMModel is the default model name. Per-request overrides win.
6060
LLMModel string
6161

62+
// BuildLLM constructs a per-request llmgate client from caller-supplied
63+
// credentials (BYOK), inheriting server defaults for any empty field.
64+
// Wired in main.go. When set, callers can pass their own key/base_url/
65+
// model via X-LLM-* request headers; nil disables per-request keys and
66+
// handlers fall back to the shared LLM client. See resolveLLM.
67+
BuildLLM func(provider, apiKey, baseURL, model string) (llmgate.Client, error)
68+
6269
// AnswerSpan / Answer hold the relevant config blocks. Default
6370
// values (AnswerSpan disabled, Answer.MaxSections=5) are safe.
6471
AnswerSpan config.AnswerSpanBlock
@@ -140,6 +147,7 @@ func Router(d Deps) http.Handler {
140147
r.Get("/{id}", d.handleGetDocument)
141148
r.Delete("/{id}", d.handleDeleteDocument)
142149
r.Get("/{id}/tree", d.handleGetTree)
150+
r.Get("/{id}/source", d.handleGetSource)
143151
})
144152

145153
r.Get("/sections/{id}", d.handleGetSection)
@@ -364,6 +372,50 @@ func (d Deps) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
364372
w.WriteHeader(http.StatusNoContent)
365373
}
366374

375+
// handleGetSource streams the original uploaded bytes for a document.
376+
// Useful for clients that want to render the source (e.g. a PDF page
377+
// preview in a viewer) without a second storage system. Served inline
378+
// with the document's content type.
379+
func (d Deps) handleGetSource(w http.ResponseWriter, r *http.Request) {
380+
id := tree.DocumentID(chi.URLParam(r, "id"))
381+
doc, err := d.DB.GetDocument(r.Context(), id, standaloneOrgID, "")
382+
if err != nil {
383+
if errors.Is(err, db.ErrNotFound) {
384+
writeErr(w, http.StatusNotFound, "document not found")
385+
return
386+
}
387+
writeErr(w, http.StatusInternalServerError, err.Error())
388+
return
389+
}
390+
if doc.SourceRef == "" {
391+
writeErr(w, http.StatusNotFound, "document has no stored source")
392+
return
393+
}
394+
rc, meta, err := d.Storage.Get(r.Context(), doc.SourceRef)
395+
if err != nil {
396+
if errors.Is(err, storage.ErrNotFound) {
397+
writeErr(w, http.StatusNotFound, "source object not found")
398+
return
399+
}
400+
writeErr(w, http.StatusInternalServerError, err.Error())
401+
return
402+
}
403+
defer func() { _ = rc.Close() }()
404+
405+
ct := doc.ContentType
406+
if ct == "" {
407+
ct = "application/octet-stream"
408+
}
409+
w.Header().Set("Content-Type", ct)
410+
if meta.Size > 0 {
411+
w.Header().Set("Content-Length", strconv.FormatInt(meta.Size, 10))
412+
}
413+
w.Header().Set("Content-Disposition", "inline")
414+
w.Header().Set("Cache-Control", "private, max-age=300")
415+
w.WriteHeader(http.StatusOK)
416+
_, _ = io.Copy(w, rc)
417+
}
418+
367419
func (d Deps) handleGetTree(w http.ResponseWriter, r *http.Request) {
368420
id := tree.DocumentID(chi.URLParam(r, "id"))
369421
t, err := d.DB.LoadTree(r.Context(), id, standaloneOrgID, "")

0 commit comments

Comments
 (0)