Package pinned self-hosted SmolVLM2 bootstrap #52
26
README.md
26
README.md
|
|
@ -20,23 +20,22 @@ The builder clones the committed `main` tree into an isolated directory, runs un
|
|||
```bash
|
||||
npm install
|
||||
|
||||
git clone --depth 1 https://github.com/ggml-org/llama.cpp /root/model-spikes/llama.cpp
|
||||
cmake -S /root/model-spikes/llama.cpp -B /root/model-spikes/llama.cpp/build -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build /root/model-spikes/llama.cpp/build -j4 --target llama-server
|
||||
# Installs the exact llama.cpp commit and verifies both GGUF SHA-256 receipts.
|
||||
scripts/bootstrap_selfhost_smolvlm.sh install
|
||||
scripts/bootstrap_selfhost_smolvlm.sh start
|
||||
scripts/bootstrap_selfhost_smolvlm.sh health
|
||||
|
||||
hf download ggml-org/SmolVLM2-2.2B-Instruct-GGUF \
|
||||
SmolVLM2-2.2B-Instruct-Q4_K_M.gguf \
|
||||
mmproj-SmolVLM2-2.2B-Instruct-Q8_0.gguf \
|
||||
--local-dir /root/model-spikes/models/smolvlm2-2.2b
|
||||
|
||||
TIMMY_MODEL_PORT=8080 scripts/run_selfhost_smolvlm.sh
|
||||
|
||||
# In another terminal
|
||||
# Run Timmy in another terminal.
|
||||
TIMMY_VISION_PROFILE=selfhost npm start
|
||||
# open http://localhost:4173
|
||||
|
||||
# When finished:
|
||||
scripts/bootstrap_selfhost_smolvlm.sh stop
|
||||
```
|
||||
|
||||
The selected bootstrap model is `SmolVLM2-2.2B-Instruct` using the official Apache-2.0 GGUF conversion. The default self-hosted endpoint is loopback-only at `http://127.0.0.1:8080/v1`. Override paths, host, port, threads, model ID, or endpoint with the `TIMMY_MODEL_*` and `TIMMY_VISION_*` environment variables.
|
||||
The bootstrap defaults to `${XDG_DATA_HOME:-$HOME/.local/share}/timmy-selfhost`, prints immutable source/model receipts with `receipt`, and binds only to `127.0.0.1:8080`. Downloads are rejected unless both pinned SHA-256 hashes match, and model weights remain outside Git. Set `TIMMY_SELFHOST_ROOT` to choose another private data directory; override host, port, threads, or build jobs with the documented `TIMMY_MODEL_*`/`TIMMY_BUILD_JOBS` environment variables.
|
||||
|
||||
The selected bootstrap model is `SmolVLM2-2.2B-Instruct` using the official Apache-2.0 GGUF conversion. It is a provisional labeling worker, not a diagnostic or clinically validated classifier.
|
||||
|
||||
## Run with a hosted provider
|
||||
|
||||
|
|
@ -120,7 +119,8 @@ Browser PWA
|
|||
├── src/hermes-agent-service.js authenticated session-bound Hermes CLI adapter
|
||||
├── src/vision-service.js server-side OpenAI-compatible provider adapter
|
||||
├── src/vision-config.js hosted/self-hosted profiles and readiness probe
|
||||
├── scripts/run_selfhost_smolvlm.sh
|
||||
├── scripts/bootstrap_selfhost_smolvlm.sh pinned install/start/health/stop lifecycle
|
||||
├── scripts/run_selfhost_smolvlm.sh custom-path foreground launcher
|
||||
├── scripts/ingest_training_photo.py
|
||||
├── localStorage saved ledger and attached photos
|
||||
└── llama.cpp / hosted API selected per server-side profile
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@
|
|||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js",
|
||||
"test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js",
|
||||
"test:ui": "node tests/ui.acceptance.mjs",
|
||||
"test:photo": "node tests/photo-first.acceptance.mjs",
|
||||
"test:sleek": "node tests/sleek-chat.acceptance.mjs",
|
||||
"check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check scripts/record_release_demo.mjs && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py",
|
||||
"check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check scripts/record_release_demo.mjs && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py",
|
||||
"check:diff": "bash scripts/check_diff.sh",
|
||||
"start": "node server.mjs"
|
||||
},
|
||||
|
|
|
|||
150
scripts/bootstrap_selfhost_smolvlm.sh
Executable file
150
scripts/bootstrap_selfhost_smolvlm.sh
Executable file
|
|
@ -0,0 +1,150 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
LLAMA_CPP_COMMIT="${TIMMY_LLAMA_CPP_COMMIT:-6d05498314db1b57f81c271080018aa2d0b89be9}"
|
||||
MODEL_SHA256="${TIMMY_MODEL_SHA256:-0cf76814555b8665149075b74ab6b5c1d428ea1d3d01c1918c12012e8d7c9f58}"
|
||||
MMPROJ_SHA256="${TIMMY_MMPROJ_SHA256:-ae07ea1facd07dd3230c4483b63e8cda96c6944ad2481f33d531f79e892dd024}"
|
||||
HOST="${TIMMY_MODEL_HOST:-127.0.0.1}"
|
||||
PORT="${TIMMY_MODEL_PORT:-8080}"
|
||||
ROOT="${TIMMY_SELFHOST_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/timmy-selfhost}"
|
||||
MODEL_DIR="$ROOT/models"
|
||||
MODEL="$MODEL_DIR/SmolVLM2-2.2B-Instruct-Q4_K_M.gguf"
|
||||
MMPROJ="$MODEL_DIR/mmproj-SmolVLM2-2.2B-Instruct-Q8_0.gguf"
|
||||
SOURCE="$ROOT/llama.cpp"
|
||||
BUILD="$SOURCE/build"
|
||||
HF_BASE="https://huggingface.co/ggml-org/SmolVLM2-2.2B-Instruct-GGUF/resolve/main"
|
||||
SERVER="$BUILD/bin/llama-server"
|
||||
RUNTIME="$ROOT/run"
|
||||
PIDFILE="$RUNTIME/llama-server.pid"
|
||||
LOGFILE="$RUNTIME/llama-server.log"
|
||||
|
||||
verify_files() {
|
||||
for file in "$MODEL" "$MMPROJ"; do
|
||||
if [[ ! -f "$file" ]]; then
|
||||
printf 'Missing required file: %s. Run %s install.\n' "$file" "$0" >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
printf '%s %s\n%s %s\n' "$MODEL_SHA256" "$MODEL" "$MMPROJ_SHA256" "$MMPROJ" | sha256sum --check --status
|
||||
}
|
||||
|
||||
download_verified() {
|
||||
local url="$1" target="$2" expected="$3" temporary="$2.download"
|
||||
rm -f "$temporary"
|
||||
curl --fail --location --retry 3 --output "$temporary" "$url"
|
||||
printf '%s %s\n' "$expected" "$temporary" | sha256sum --check --status || {
|
||||
rm -f "$temporary"
|
||||
printf 'Downloaded file failed pinned SHA-256 verification: %s\n' "$target" >&2
|
||||
return 1
|
||||
}
|
||||
mv "$temporary" "$target"
|
||||
}
|
||||
|
||||
install_worker() {
|
||||
command -v git >/dev/null
|
||||
command -v cmake >/dev/null
|
||||
command -v curl >/dev/null
|
||||
mkdir -p "$ROOT" "$MODEL_DIR"
|
||||
if [[ ! -d "$SOURCE/.git" ]]; then
|
||||
git clone https://github.com/ggml-org/llama.cpp "$SOURCE"
|
||||
fi
|
||||
git -C "$SOURCE" fetch origin "$LLAMA_CPP_COMMIT"
|
||||
git -C "$SOURCE" checkout --detach "$LLAMA_CPP_COMMIT"
|
||||
cmake -S "$SOURCE" -B "$BUILD" -DCMAKE_BUILD_TYPE=Release -DLLAMA_CURL=OFF
|
||||
cmake --build "$BUILD" --parallel "${TIMMY_BUILD_JOBS:-4}" --target llama-server
|
||||
download_verified "$HF_BASE/SmolVLM2-2.2B-Instruct-Q4_K_M.gguf" "$MODEL" "$MODEL_SHA256"
|
||||
download_verified "$HF_BASE/mmproj-SmolVLM2-2.2B-Instruct-Q8_0.gguf" "$MMPROJ" "$MMPROJ_SHA256"
|
||||
verify_files
|
||||
printf 'Pinned model files verified.\n'
|
||||
}
|
||||
|
||||
health_worker() {
|
||||
curl --fail --silent --show-error --retry 5 --retry-connrefused --retry-delay 1 --max-time 3 "http://$HOST:$PORT/v1/models" | grep -q 'SmolVLM2-2.2B-Instruct'
|
||||
printf 'Self-hosted worker healthy at http://%s:%s/v1\n' "$HOST" "$PORT"
|
||||
}
|
||||
|
||||
start_worker() {
|
||||
verify_files
|
||||
if [[ ! -x "$SERVER" ]]; then
|
||||
printf 'Missing llama-server executable: %s. Run %s install.\n' "$SERVER" "$0" >&2
|
||||
return 1
|
||||
fi
|
||||
mkdir -p "$RUNTIME"
|
||||
if [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
|
||||
health_worker
|
||||
return
|
||||
fi
|
||||
rm -f "$PIDFILE"
|
||||
nohup "$SERVER" \
|
||||
--model "$MODEL" \
|
||||
--mmproj "$MMPROJ" \
|
||||
--alias SmolVLM2-2.2B-Instruct \
|
||||
--host "$HOST" \
|
||||
--port "$PORT" \
|
||||
--ctx-size 4096 \
|
||||
--parallel 1 \
|
||||
--threads "${TIMMY_MODEL_THREADS:-4}" \
|
||||
--no-webui >"$LOGFILE" 2>&1 &
|
||||
printf '%s\n' "$!" > "$PIDFILE"
|
||||
for _ in {1..30}; do
|
||||
if health_worker >/dev/null 2>&1; then
|
||||
printf 'Self-hosted worker started privately at http://%s:%s/v1\n' "$HOST" "$PORT"
|
||||
return
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
kill "$(cat "$PIDFILE")" 2>/dev/null || true
|
||||
rm -f "$PIDFILE"
|
||||
printf 'Worker did not become healthy; inspect %s\n' "$LOGFILE" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
stop_worker() {
|
||||
if [[ ! -f "$PIDFILE" ]]; then
|
||||
printf 'Self-hosted worker is not running.\n'
|
||||
return
|
||||
fi
|
||||
local pid
|
||||
pid="$(cat "$PIDFILE")"
|
||||
kill "$pid" 2>/dev/null || true
|
||||
for _ in {1..25}; do
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
rm -f "$PIDFILE"
|
||||
printf 'Self-hosted worker stopped.\n'
|
||||
return
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
rm -f "$PIDFILE"
|
||||
printf 'Self-hosted worker stopped.\n'
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
receipt)
|
||||
printf 'llama.cpp commit: %s\n' "$LLAMA_CPP_COMMIT"
|
||||
printf 'SmolVLM2-2.2B-Instruct-Q4_K_M.gguf sha256: %s\n' "$MODEL_SHA256"
|
||||
printf 'mmproj-SmolVLM2-2.2B-Instruct-Q8_0.gguf sha256: %s\n' "$MMPROJ_SHA256"
|
||||
printf 'bind address: %s:%s\n' "$HOST" "$PORT"
|
||||
;;
|
||||
verify)
|
||||
verify_files
|
||||
printf 'Pinned model files verified.\n'
|
||||
;;
|
||||
install)
|
||||
install_worker
|
||||
;;
|
||||
start)
|
||||
start_worker
|
||||
;;
|
||||
health)
|
||||
health_worker
|
||||
;;
|
||||
stop)
|
||||
stop_worker
|
||||
;;
|
||||
*)
|
||||
printf 'Usage: %s {install|start|health|stop|receipt|verify}\n' "$0" >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
|
|
@ -105,7 +105,7 @@ await caption('Automated checks replay this synthetic path before review', 1200)
|
|||
await caption('One clear photo action. Manual logging stays one tap away.', 1500);
|
||||
await tap('[data-scan]', 450);
|
||||
await page.getByText(/Self-hosted model ready/i).waitFor();
|
||||
await caption('The self-hosted vision route is ready', 1000);
|
||||
await caption('The pinned bootstrap verifies both model files before starting on private loopback', 1500);
|
||||
await page.locator('#ai-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
|
||||
await caption('Nothing uploads until explicit consent', 1100);
|
||||
await page.locator('#ai-consent').check();
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { readFile } from 'node:fs/promises';
|
|||
const workflowPath = new URL('../.gitea/workflows/quality.yml', import.meta.url);
|
||||
const diffCheckPath = new URL('../scripts/check_diff.sh', import.meta.url);
|
||||
const pythonRequirementsPath = new URL('../requirements-test.txt', import.meta.url);
|
||||
const packagePath = new URL('../package.json', import.meta.url);
|
||||
|
||||
test('Gitea CI gates pull requests and main with the reproducible quality suite', async () => {
|
||||
const workflow = await readFile(workflowPath, 'utf8');
|
||||
|
|
@ -31,6 +32,12 @@ test('CI pins the Python image dependency required by the full unit suite', asyn
|
|||
assert.match(requirements, /^Pillow==12\.3\.0$/m);
|
||||
});
|
||||
|
||||
test('default unit suite includes the self-host bootstrap contract', async () => {
|
||||
const packageJson = JSON.parse(await readFile(packagePath, 'utf8'));
|
||||
|
||||
assert.match(packageJson.scripts.test, /tests\/selfhost-bootstrap\.test\.js/);
|
||||
});
|
||||
|
||||
test('diff hygiene checks only the pull request or latest commit range', async () => {
|
||||
const script = await readFile(diffCheckPath, 'utf8');
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ test('release demo visibly explains the CI-protected browser path without overst
|
|||
assert.match(demo, /Automated checks replay this synthetic path before review/);
|
||||
assert.match(demo, /One clear photo action\. Manual logging stays one tap away\./);
|
||||
assert.match(demo, /tests\/fixtures\/synthetic-type4\.jpg/);
|
||||
assert.match(demo, /The pinned bootstrap verifies both model files before starting on private loopback/);
|
||||
assert.match(demo, /AI may suggest visible form, broad color, and image quality — never symptoms or diagnosis/);
|
||||
assert.match(demo, /Hermes Agent connected/);
|
||||
assert.match(demo, /Photos stay out of chat/);
|
||||
|
|
|
|||
92
tests/selfhost-bootstrap.test.js
Normal file
92
tests/selfhost-bootstrap.test.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
const script = new URL('../scripts/bootstrap_selfhost_smolvlm.sh', import.meta.url).pathname;
|
||||
|
||||
test('self-host bootstrap reports immutable source and model receipts', () => {
|
||||
const result = spawnSync('bash', [script, 'receipt'], { encoding: 'utf8' });
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /llama\.cpp commit: [0-9a-f]{40}/);
|
||||
assert.match(result.stdout, /SmolVLM2-2\.2B-Instruct-Q4_K_M\.gguf sha256: [0-9a-f]{64}/);
|
||||
assert.match(result.stdout, /mmproj-SmolVLM2-2\.2B-Instruct-Q8_0\.gguf sha256: [0-9a-f]{64}/);
|
||||
assert.match(result.stdout, /bind address: 127\.0\.0\.1:8080/);
|
||||
});
|
||||
|
||||
test('self-host bootstrap rejects model files that do not match pinned hashes', () => {
|
||||
const result = spawnSync('bash', [script, 'verify'], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, TIMMY_SELFHOST_ROOT: '/tmp/timmy-missing-bootstrap' },
|
||||
});
|
||||
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /Run .* install|Missing required file/);
|
||||
});
|
||||
|
||||
test('self-host bootstrap completes the pinned private worker lifecycle from an empty directory', async () => {
|
||||
const sandbox = await mkdtemp(join(tmpdir(), 'timmy-bootstrap-'));
|
||||
const bin = join(sandbox, 'bin');
|
||||
const root = join(sandbox, 'worker');
|
||||
spawnSync('mkdir', ['-p', bin]);
|
||||
const commands = join(sandbox, 'commands.log');
|
||||
const tools = {
|
||||
git: `#!/bin/sh\nprintf 'git %s\\n' "$*" >> "$COMMAND_LOG"\nif [ "$1" = clone ]; then mkdir -p "$3/.git"; fi\n`,
|
||||
cmake: `#!/bin/sh\nprintf 'cmake %s\\n' "$*" >> "$COMMAND_LOG"\nbuild=''; prev=''; for arg in "$@"; do [ "$prev" = --build ] && build="$arg"; prev="$arg"; done\nif [ -n "$build" ]; then mkdir -p "$build/bin"; printf '#!/bin/sh\\n' > "$build/bin/llama-server"; chmod +x "$build/bin/llama-server"; fi\n`,
|
||||
curl: `#!/bin/sh\nprintf 'curl %s\\n' "$*" >> "$COMMAND_LOG"\nout=''; prev=''; for arg in "$@"; do { [ "$prev" = -o ] || [ "$prev" = --output ]; } && out="$arg"; prev="$arg"; done\ncase "$out" in *mmproj*) printf 'mmproj\\n' > "$out";; *) printf 'model\\n' > "$out";; esac\n`,
|
||||
};
|
||||
for (const [name, contents] of Object.entries(tools)) {
|
||||
await writeFile(join(bin, name), contents);
|
||||
await chmod(join(bin, name), 0o755);
|
||||
}
|
||||
|
||||
const result = spawnSync('bash', [script, 'install'], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${bin}:${process.env.PATH}`,
|
||||
COMMAND_LOG: commands,
|
||||
TIMMY_SELFHOST_ROOT: root,
|
||||
TIMMY_MODEL_SHA256: '98ad61a25e3683b6adf2474b01bbe1c27de6aad2ce3a80ff4140fe473c14e691',
|
||||
TIMMY_MMPROJ_SHA256: 'ce52f16b076dcb922c667c08d62881c85fa26aca7cc60394050d5bb1153b7276',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const log = await readFile(commands, 'utf8');
|
||||
assert.match(log, /git clone https:\/\/github\.com\/ggml-org\/llama\.cpp/);
|
||||
assert.match(log, /checkout --detach 6d05498314db1b57f81c271080018aa2d0b89be9/);
|
||||
assert.match(log, /curl .*SmolVLM2-2\.2B-Instruct-Q4_K_M\.gguf/);
|
||||
assert.match(result.stdout, /Pinned model files verified/);
|
||||
|
||||
const fakeServer = join(root, 'llama.cpp', 'build', 'bin', 'llama-server');
|
||||
await writeFile(fakeServer, `#!/usr/bin/env python3
|
||||
import argparse, json
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
parser = argparse.ArgumentParser(add_help=False)
|
||||
parser.add_argument('--host'); parser.add_argument('--port', type=int)
|
||||
args, _ = parser.parse_known_args()
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
body = json.dumps({'data': [{'id': 'SmolVLM2-2.2B-Instruct'}]}).encode()
|
||||
self.send_response(200); self.send_header('Content-Type', 'application/json'); self.end_headers(); self.wfile.write(body)
|
||||
def log_message(self, *_): pass
|
||||
HTTPServer((args.host, args.port), Handler).serve_forever()
|
||||
`);
|
||||
await chmod(fakeServer, 0o755);
|
||||
const lifecycleEnv = {
|
||||
...process.env,
|
||||
TIMMY_SELFHOST_ROOT: root,
|
||||
TIMMY_MODEL_PORT: String(19000 + process.pid % 1000),
|
||||
TIMMY_MODEL_SHA256: '98ad61a25e3683b6adf2474b01bbe1c27de6aad2ce3a80ff4140fe473c14e691',
|
||||
TIMMY_MMPROJ_SHA256: 'ce52f16b076dcb922c667c08d62881c85fa26aca7cc60394050d5bb1153b7276',
|
||||
};
|
||||
for (const command of ['start', 'health', 'stop']) {
|
||||
const commandResult = spawnSync('bash', [script, command], { encoding: 'utf8', env: lifecycleEnv });
|
||||
assert.equal(commandResult.status, 0, `${command}: ${commandResult.stderr}`);
|
||||
}
|
||||
await rm(sandbox, { recursive: true, force: true });
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user