mirror of
https://github.com/piabrota/mu.git
synced 2026-11-07 06:15:59 -10:00
Local models: ygg-models.sh, ygg-gpu.sh, and local roles in the proof and smoke scripts (#89)
* ygg-gpu.sh setup|check: the toolkit and the CDI spec in the Podman machine, repeatable (local-models LM-T15) Co-Authored-By: Claude Opus 5.5 <[email protected]> * ygg-models.sh start|stop|status|load, and the qwen scripts find the server through it (local-models LM-T12) qwen-local.sh and its test are removed; qwen-lib.sh reads ygg-models.sh status. Co-Authored-By: Claude Opus 5.5 <[email protected]> * ygg-models.sh: --folder, exact names, reserved networks, load retry; qwen scripts tell engine errors from not running (local-models LM-T12) Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01H5rHSQVuXrzFdTaNXZdSde * The proof and smoke scripts take opencode:local/<name> roles and --models FILE (local-models LM-T13) A local role writes models.file into the config and, for the reviewer, no reasoning line. The OpenRouter key and the usd caps are asked for only when a role is on OpenRouter. A local role without --models is refused before anything is made. The default roles give the same config as before. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01H5rHSQVuXrzFdTaNXZdSde * ygg-models.sh uses engine-side mount paths, a template step, and a hardened container (local-models review) Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01H5rHSQVuXrzFdTaNXZdSde * ygg-gpu.sh checks the engine before any wsl.exe -d and reads the state with one probe (local-models review) Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01H5rHSQVuXrzFdTaNXZdSde * Proof and smoke refuse two local models; the how-to uses the template step and the image (local-models review) Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01H5rHSQVuXrzFdTaNXZdSde * ygg-gpu.sh sends root scripts on stdin to sh -s, and the template step knows a fixed template (local-models review) Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01H5rHSQVuXrzFdTaNXZdSde * The example model list takes temp, top-k and top-p from the model file, and sets only min-p (local-models review) The Qwen GGUF carries general.sampling.temp, top_k and top_p, which llama-server uses unless a preset key overrides them. The founder uses the model's own values in most cases. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01H5rHSQVuXrzFdTaNXZdSde --------- Co-authored-by: Bruno Guilhermo de Barros Valério <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
22 files changed
+1427
-436
No files matched your search
@@ -46,13 +46,15 @@ folder.
|
||||
| Open a pull request as `ouroboric-pr-bot` and arm auto-merge on it: the default way an agent opens one. A human's approval still decides the merge; if arming fails the PR stays open and the script says why. A PR stacked on a branch other than the default one is opened without auto-merge | `scripts/pr-open.sh <repo> [gh pr create args]`, e.g. `scripts/pr-open.sh gleipnir --base main --head my-branch --title "…" --body-file body.md`. Prints the PR's URL. Never merges directly, never `--admin` |
|
||||
| Build `ygg` from the umbrella's checkouts, so a smoke or proof run never uses a stale one | `scripts/ygg-build.sh [--yggdrasil DIR] [--out FILE] [--allow-behind] [--force]`. Refuses a dirty module or one behind its local `origin/main` (it fetches nothing); builds to the `ygg` first on PATH (else `$(go env GOPATH)/bin/ygg`) and writes the commits to `OUT.build.txt`; a link is followed to its target; an error if `command -v ygg` is not OUT. Never overwrites a file that is not a Go binary without `--force`. The smoke and proof scripts refuse a `ygg` from PATH whose facts do not name odin's HEAD (`--any-ygg` skips that) |
|
||||
| Clean up after runs that have ended: the empty pods and leftover containers | `scripts/ygg-clean.sh --dry-run [--folder DIR] [--keep PROJECT]... [PROJECT...]`, then without `--dry-run`. Runs `ygg doctor` on each project under DIR (default `~/ygg-it`) with the config its runs used; skips a project with a live run (its lock in `/proc/locks`, or open in a process) and refuses when it cannot tell; never deletes a run, a project or an image, and never starts or stops the engine |
|
||||
| Set up and check the GPU for the engine: the NVIDIA toolkit (pinned 1.20.1) and the CDI spec in the Podman machine's distro on WSL2, repeatable after a driver update or on a new machine | `scripts/ygg-gpu.sh setup\|check [--machine NAME] [--folder DIR]`. `setup` installs only what is missing and regenerates a stale spec, then runs `check`; `check` runs the CUDA image (by digest) through `ygg-engine.sh` and prints `gpu: <name> <memory>`, or exits 3 with `missing: toolkit\|spec\|stale spec` and the fix, or 2 when the engine does not answer. Root commands go through `wsl.exe -u root`, never sudo; it never starts, stops or re-creates the machine |
|
||||
| Start, stop and check the local model server (llama-server and the model gate in one container, on the engine's GPU), from the operator's model list; the qwen scripts find it through it | `scripts/ygg-models.sh [--models FILE] [--folder DIR] start\|stop\|status\|load NAME`. FILE defaults to `${XDG_CONFIG_HOME:-~/.config}/ygg/models.yaml` and DIR (holds `factory.yaml`, which names the engine) to `~/ygg-it` (example list: `scripts/testdata/models/models.example.yaml`). `start` runs `ygg-gpu.sh check` first (a stale spec is set up again), builds the image only when its tag is missing (long, CUDA), creates the internal network when absent, writes the preset and runs the container with `127.0.0.1:<host_port>` as its only published port; `status` prints `running on <url>, loaded: <name\|none>` or exits 3; `stop` keeps the network and the image. Engine calls go through `ygg-engine.sh` |
|
||||
| Any real-engine run (Podman, devcontainer, the sandbox): the engine, registry config and checks | `scripts/ygg-engine.sh check\|env\|run --folder DIR`. Never set `CONTAINER_HOST` by hand: the root socket is the wrong one |
|
||||
| The realms smoke runs (v0-H13, v0-H15) and their evidence | `scripts/ygg-smoke-realms.sh`, `scripts/ygg-smoke-loop.sh`, `scripts/ygg-evidence.sh` |
|
||||
| The grader smoke on a real engine (v0-H16), with the public sample suite | `scripts/ygg-smoke-grade.sh [--build]`. Never a hidden suite by default; `--task ID --suite-dir DIR --reference DIR [--suite-engine DIR]` points the grader at a suite store as it is (never copied or read) and grades the task's reference solution; its output holds numbers only |
|
||||
| The runtime smoke (gallery-runtime T26): the templates' releases started by Niflheim's product start, in the task image, on an internal network, checked from a second container | `scripts/ygg-smoke-runtime.sh [--image REF] [--folder DIR] [--yggdrasil DIR] [--api-image REF] [--web-image REF]`. It prints PASS or FAIL per check and never prints the store's key |
|
||||
| The real-agent smoke (v0-H17): costs money; `--dry-run` first; the cost from the ledger; a start from a planted-fault leaf; another implementer; a note before the brief; the repair path from the ledger | `scripts/ygg-smoke-real.sh --dry-run`, then without it; `--from-leaf LEAF`, `--implementer AGENT:MODEL`, `--brief-note FILE` (narrows the implementer's task; the run is graded against a derived task version in its folder; the ygg line says if the build used go.work); `scripts/ygg-cost.sh LEDGER`, `scripts/ygg-repair-path.sh LEDGER`. It never prints the credentials file |
|
||||
| The real-agent smoke (v0-H17): costs money; `--dry-run` first; the cost from the ledger; a start from a planted-fault leaf; another implementer; a note before the brief; the repair path from the ledger | `scripts/ygg-smoke-real.sh --dry-run`, then without it; `--from-leaf LEAF`, `--implementer AGENT:MODEL`, `--implementer opencode:local/<name>` with `--models FILE` (a local model; OpenRouter key and `usd` caps only when a role is on OpenRouter), `--brief-note FILE` (narrows the implementer's task; the run is graded against a derived task version in its folder; the ygg line says if the build used go.work); `scripts/ygg-cost.sh LEDGER`, `scripts/ygg-repair-path.sh LEDGER`. It never prints the credentials file |
|
||||
| Compare reviewer models: costs money unless `--dry-run`; on the planted-fault leaves in `scripts/testdata/reviewer-bench`; runs the OpenCode version gleipnir pins (installed once with npm; `--opencode BIN`, `--any-opencode`) | `scripts/ygg-reviewer-bench.sh --model MODEL --leaf DIR --sessions N --dry-run`, then without it |
|
||||
| A Phase 6 proof run (v0-T290, v0-T291): costs money without `--dry-run`; `--dry-run` first; the `software` graph on the real task with three roles and caps you set, then the grade and the evidence; never reads the suites folder or prints the credentials | `scripts/ygg-proof-run.sh --name NAME --dry-run [--planner AGENT:MODEL --implementer AGENT:MODEL --reviewer AGENT:MODEL --run-usd N ...]`, then without `--dry-run`. `--reviewer-reasoning EFFORT` (low, medium, high or off; default `medium`) sets the reviewer's reasoning effort; `--build` builds the sandbox images first; `--baseline RUN_ID` compares with a run of an earlier proof run; `--continue RUN_ID --approve GATE`, `--reject GATE --reason TEXT` or `--resume` continues a stopped or paused run (a rejection starts sessions, so it needs the credentials as an approval does); `--clip` copies the report. The run's output is in `run.txt` and the evidence in `report.txt`, in `DIR/proof-NAME-TIME`. The example config is `scripts/testdata/proof/factory.proof.example.yaml` |
|
||||
| A Phase 6 proof run (v0-T290, v0-T291): costs money without `--dry-run`; `--dry-run` first; the `software` graph on the real task with three roles and caps you set, then the grade and the evidence; never reads the suites folder or prints the credentials | `scripts/ygg-proof-run.sh --name NAME --dry-run [--planner AGENT:MODEL --implementer AGENT:MODEL --reviewer AGENT:MODEL --run-usd N ...]`, then without `--dry-run`. `--reviewer-reasoning EFFORT` (low, medium, high or off; default `medium`) sets the reviewer's reasoning effort; `--build` builds the sandbox images first; `--baseline RUN_ID` compares with a run of an earlier proof run; `--continue RUN_ID --approve GATE`, `--reject GATE --reason TEXT` or `--resume` continues a stopped or paused run (a rejection starts sessions, so it needs the credentials as an approval does); `--clip` copies the report; a role may be `opencode:local/<name>` (a model of your list) with `--models FILE`, and then asks for `OPENROUTER_API_KEY` and a `usd` cap only when a role is on OpenRouter. The run's output is in `run.txt` and the evidence in `report.txt`, in `DIR/proof-NAME-TIME`. The example config is `scripts/testdata/proof/factory.proof.example.yaml` |
|
||||
| Build a plan from reference commits, or execute one task by task | `scripts/plan-*` (`plan-generate.py`, `plan-replay.py`, `plan-states.sh`, `plan-red.sh`, `plan-defences.py`; `plan-blocks.py`, `plan-task-start.sh`, `plan-task-done.sh`, `plan-task-land.sh`) |
|
||||
| Check that the repos have the required setup, and that the realms' shared files match | `scripts/as-bot.sh --read all scripts/check-repos.sh`; `--check shared` runs the realms check alone; `--check operator` (local, no GitHub) checks that no tracked file names you; `--check services` (not a default, once per call) keeps the services' versions equal in niflheim-local, mimir and helheim-go |
|
||||
| Scan git history for secrets | `scripts/scan-history.sh` |
|
||||
|
||||
@@ -19,71 +19,56 @@ You need:
|
||||
|
||||
- An RTX 4090, or another 24 GB NVIDIA card, with a Windows driver that offers CUDA 13.3 or newer in
|
||||
WSL. Check with `nvidia-smi`.
|
||||
- WSL2 with Ubuntu 22.04, about 25 GB of free disk, Homebrew, `uv`, and `sudo` for one step.
|
||||
- WSL2 with Ubuntu 22.04, about 25 GB of free disk, Homebrew, `uv`, and the factory's engine (a Podman machine
|
||||
with GPU access: `scripts/ygg-gpu.sh setup` prepares it). The server image builds `llama-server` itself, so the
|
||||
host needs no CUDA toolkit, no cmake and no build.
|
||||
- Claude Code (`claude`) on `PATH`, and the personal Claude Code config in `~/.claude-personal`.
|
||||
|
||||
The model, the llama.cpp build and the run state live in `~/.local/share/qwen-local` (`QWEN_HOME`),
|
||||
outside every repo.
|
||||
The model and the run state of the measurement scripts live in `~/.local/share/qwen-local` (`QWEN_HOME`),
|
||||
outside every repo. The engine sees host files only through the `sandbox.mounts` of the factory folder's
|
||||
`factory.yaml` (for the founder's setup, `~/ygg-it`), so the weights and the state folder of the model list must
|
||||
be under a folder of those mounts: put the model under `~/ygg-it` and link it from there.
|
||||
A symlink in the weights folder that points outside the mounts does not resolve in the engine, so link only to
|
||||
files under them.
|
||||
|
||||
## Set up the server
|
||||
|
||||
1. Install the CUDA toolkit. This is the only step that needs `sudo`. Install only
|
||||
`cuda-toolkit-13-3`. Never install `cuda`, `cuda-drivers` or `nvidia-driver-*`: in WSL, the Windows
|
||||
driver is the GPU driver.
|
||||
|
||||
```zsh
|
||||
cd /tmp && wget https://developer.download.nvidia.com/compute/cuda/repos/wsl-ubuntu/x86_64/cuda-keyring_1.1-1_all.deb
|
||||
sudo dpkg -i cuda-keyring_1.1-1_all.deb
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y cuda-toolkit-13-3
|
||||
```
|
||||
|
||||
`/usr/local/cuda/bin/nvcc --version` now prints `release 13.3`.
|
||||
|
||||
1. Install cmake and OpenCode: `brew install cmake opencode`.
|
||||
1. Build `llama-server` with CUDA. Keep Homebrew off `PATH` for the build: Homebrew's linker misses
|
||||
the CUDA libraries, and Homebrew's OpenSSL needs a newer glibc than Ubuntu 22.04 has.
|
||||
|
||||
```zsh
|
||||
git clone https://github.com/ggml-org/llama.cpp ~/.local/share/qwen-local/llama.cpp
|
||||
cd ~/.local/share/qwen-local/llama.cpp
|
||||
git checkout 4c3c581 # the build the notes measured; leave this out for the latest
|
||||
export PATH=/usr/local/cuda/bin:/usr/bin:/bin
|
||||
/home/linuxbrew/.linuxbrew/bin/cmake -B build -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=89 \
|
||||
-DCMAKE_BUILD_TYPE=Release -DLLAMA_OPENSSL=OFF
|
||||
/home/linuxbrew/.linuxbrew/bin/cmake --build build --config Release -j 6 --target llama-server
|
||||
```
|
||||
|
||||
Open a new terminal afterwards, to get your usual `PATH` back. `-j 6` keeps the build inside a
|
||||
12 GB WSL memory cap.
|
||||
|
||||
1. Install OpenCode: `brew install opencode`.
|
||||
1. Download the model (16.5 GB) and its MTP draft file (1.4 GB), and link them to the names the
|
||||
scripts use:
|
||||
|
||||
```zsh
|
||||
mkdir -p ~/.local/share/qwen-local/models && cd ~/.local/share/qwen-local/models
|
||||
mkdir -p ~/ygg-it/models/weights && cd ~/ygg-it/models/weights
|
||||
uvx --from huggingface_hub hf download unsloth/Qwen3.8-27B-GGUF \
|
||||
Qwen3.8-27B-UD-Q4_K_M.gguf MTP/mtp-Qwen3.8-27B-Q4_0.gguf --local-dir .
|
||||
ln -sfn Qwen3.8-27B-UD-Q4_K_M.gguf model.gguf
|
||||
ln -sfn MTP/mtp-Qwen3.8-27B-Q4_0.gguf mtp.gguf
|
||||
```
|
||||
|
||||
1. Start the server: `scripts/qwen-local.sh start`. It is ready in about 25 seconds. To see the
|
||||
flags, run `scripts/qwen-local.sh cmd`.
|
||||
1. Save the fixed chat template, then restart, so Claude Code works:
|
||||
1. Write your model list from `scripts/testdata/models/models.example.yaml` to
|
||||
`~/.config/ygg/models.yaml`. Set `weights` to the folder from the last step and `state` to another folder under
|
||||
`~/ygg-it` (both must be under a folder of `sandbox.mounts`; `ygg models server --config` refuses others), then
|
||||
start the server: `scripts/ygg-models.sh start`. It builds the image on the first start, which takes long. The
|
||||
flags are the list's `args`; the preset `ygg models preset` prints shows them.
|
||||
1. Fix the chat template. Claude Code sends a system message after the first, which the model's own template
|
||||
refuses. With the server running, load the model and save the fixed template:
|
||||
|
||||
```zsh
|
||||
scripts/qwen-local.sh template
|
||||
scripts/qwen-local.sh stop && scripts/qwen-local.sh start
|
||||
scripts/ygg-models.sh load qwen3.8-27b
|
||||
scripts/ygg-models.sh template qwen3.8-27b
|
||||
```
|
||||
|
||||
It saves `chat-template.jinja` in the weights folder. Uncomment `chat-template-file` in the list, then run
|
||||
`scripts/ygg-models.sh stop && scripts/ygg-models.sh start`.
|
||||
1. Set up the local Claude Code config dir, `~/.claude-qwen`: `scripts/qwen-claude.sh init`. It
|
||||
installs the personal setup's three plugins and links its skills. It never touches `~/.claude` or
|
||||
`~/.claude-personal`.
|
||||
|
||||
The server's settings come from environment variables: `QWEN_CTX` (102400), `QWEN_EFFORT` (`low`,
|
||||
`medium`, `xhigh` or `off`), `QWEN_MTP_ARGS` (empty turns MTP off), `QWEN_CACHE_RAM` (2048) and
|
||||
`QWEN_CTX_CHECKPOINTS` (4). The comment block at the top of `scripts/qwen-local.sh` explains each one.
|
||||
The server's settings are the model list's `context` and `args` (reasoning effort, cache and checkpoint
|
||||
keys). The launchers read `QWEN_CTX` (102400; keep it equal to `context`), `QWEN_ALIAS` (the list's model
|
||||
name), `QWEN_MODELS` (the list file, when it is not the default) and `QWEN_FOLDER` (the factory folder, when it
|
||||
is not `~/ygg-it`). The comment block at the top of
|
||||
`scripts/ygg-models.sh` explains the commands.
|
||||
|
||||
## Use it
|
||||
|
||||
@@ -92,8 +77,8 @@ The server's settings come from environment variables: `QWEN_CTX` (102400), `QWE
|
||||
approval that it cannot get.
|
||||
- **Claude Code:** `scripts/qwen-claude.sh` opens the interactive session, and
|
||||
`scripts/qwen-claude.sh -p "..."` runs one prompt headless. It asks before it acts. Claude Code's own
|
||||
effort setting has no effect here; restart the server with another `QWEN_EFFORT` instead.
|
||||
- **Status and stop:** `scripts/qwen-local.sh status` and `scripts/qwen-local.sh stop`.
|
||||
effort setting has no effect here; change `reasoning-effort` in the list and restart the server instead.
|
||||
- **Status and stop:** `scripts/ygg-models.sh status` and `scripts/ygg-models.sh stop`.
|
||||
|
||||
Both launchers send model calls only to the local server, and remove `ANTHROPIC_API_KEY`,
|
||||
`CLAUDE_CODE_OAUTH_TOKEN` and `OPENROUTER_API_KEY` from the session. `qwen-claude.sh` also removes the
|
||||
@@ -110,8 +95,7 @@ Start the server, then run these commands. The figures in each answer come from
|
||||
python3 -I scripts/qwen-measure.py speed --runs 3 --max-tokens 512 /tmp/short.txt
|
||||
```
|
||||
|
||||
To measure without MTP, restart with `QWEN_MTP_ARGS="" scripts/qwen-local.sh start` and run it
|
||||
again.
|
||||
To measure without MTP, remove its keys from the list's `args`, restart and run it again.
|
||||
|
||||
1. Speed with about 95K tokens of context:
|
||||
|
||||
@@ -121,16 +105,19 @@ Start the server, then run these commands. The figures in each answer come from
|
||||
python3 -I scripts/qwen-measure.py speed --runs 1 --max-tokens 256 /tmp/long.txt
|
||||
```
|
||||
|
||||
1. Reasoning effort: restart with `QWEN_EFFORT=low`, `medium` and `xhigh` in turn, and run
|
||||
1. Reasoning effort: set `reasoning-effort` to `low`, `medium` and `xhigh` in the list in turn, restarting each time, and run
|
||||
`speed --runs 1 --max-tokens 20000 /tmp/short.txt` each time.
|
||||
1. The server's memory over a growing conversation:
|
||||
1. The server's memory over a growing conversation. The server's process runs in the Podman machine's own
|
||||
WSL distro, so `/proc` on the host does not show it, and `qwen-measure.py growth` (which needs a host
|
||||
pid) does not apply. Watch the container instead, through the engine script, while a long OpenCode or
|
||||
Claude Code session runs in another terminal:
|
||||
|
||||
```zsh
|
||||
python3 -I scripts/qwen-measure.py growth --turns 20 --pid "$(cat ~/.local/share/qwen-local/run/server.pid)" /tmp/long.txt
|
||||
watch -n 10 'scripts/ygg-engine.sh run -- podman stats --no-stream ygg-models'
|
||||
```
|
||||
|
||||
To see the problem the defaults fix, restart with `QWEN_CTX_CHECKPOINTS=32 QWEN_CACHE_RAM=8192`
|
||||
and run it again.
|
||||
To see the problem the defaults fix, set `ctx-checkpoints` to 32 and `cache-ram` to 8192 in the
|
||||
list's `args`, restart and watch again.
|
||||
|
||||
## Repeat the comparison
|
||||
|
||||
@@ -138,7 +125,7 @@ The comparison runs one task three times. Each run gets a throwaway worktree of
|
||||
the same prompt. Tools are approved without asking, so use a task you trust the agents with.
|
||||
|
||||
```zsh
|
||||
scripts/qwen-local.sh start
|
||||
scripts/ygg-models.sh start
|
||||
scripts/qwen-compare.sh --repo ../yggdrasil/odin --commit bc973e3 \
|
||||
--prompt docs/5-research/2026-10-19-local-qwen-comparison/prompt.txt --out /tmp/qwen-compare-b071
|
||||
```
|
||||
@@ -171,13 +158,14 @@ provider's `baseURL` to `http://127.0.0.1:8099/v1`, and pass `--standalone`.
|
||||
## Troubleshooting
|
||||
|
||||
- **Every Claude Code request fails with HTTP 500, "System message must be at the beginning".** The
|
||||
fixed chat template is missing. Run the template step in [Set up the server](#set-up-the-server).
|
||||
fixed chat template is missing. Run `scripts/ygg-models.sh template qwen3.8-27b` and restart, as in
|
||||
[Set up the server](#set-up-the-server).
|
||||
- **The server disappears in a long session, and `dmesg` shows `Out of memory: Killed process
|
||||
(llama-server)`.** WSL ran out of system RAM. Keep `QWEN_CACHE_RAM` and `QWEN_CTX_CHECKPOINTS` low,
|
||||
(llama-server)`.** WSL ran out of system RAM. Keep `cache-ram` and `ctx-checkpoints` low in the list's `args`,
|
||||
or raise `memory=` in `C:\Users\<you>\.wslconfig`. Run `wsl --shutdown` from Windows to apply it; this
|
||||
closes every WSL session.
|
||||
- **OpenCode ignores a changed config.** A background `opencode serve --service` from an earlier
|
||||
session holds the old one. `scripts/qwen-opencode.sh` avoids it with `--standalone`. To stop the
|
||||
service, run `opencode service stop`.
|
||||
- **`start` says the port already answers.** Another program uses port 8088. Stop it, or set
|
||||
`QWEN_PORT`.
|
||||
- **`start` fails to publish the port.** Another program uses `host_port` (8088). Stop it, or change
|
||||
`host_port` in the list.
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run Claude Code against the local Qwen 3.8 27B server, in a config dir of its own that has no
|
||||
# account (docs/5-research/2026-10-19-local-qwen-design.md). Start the server first: qwen-local.sh start.
|
||||
# account (docs/5-research/2026-10-19-local-qwen-design.md). Start the server first: ygg-models.sh start.
|
||||
# qwen-claude.sh init set up the config dir: the personal setup's plugins, skills and
|
||||
# merge hook, without its account, memory or account guard
|
||||
# qwen-claude.sh [CLAUDE ARGS...] run Claude Code on the local model
|
||||
|
||||
@@ -7,11 +7,13 @@
|
||||
# Tools are approved without asking: OpenCode gets `run --auto`, and Claude Code gets acceptEdits with the
|
||||
# --allow Bash patterns (by default Go's: "Bash(go *)", "Bash(GOWORK=off go *)", "Bash(gofmt *)", plus
|
||||
# "Bash(git diff*)" and "Bash(git status*)"). The worktrees are throwaway, but the agents run on this host.
|
||||
# Start the server first (qwen-local.sh start) for the local runs. The hosted run uses the personal config
|
||||
# Start the server first (ygg-models.sh start) for the local runs. The hosted run uses the personal config
|
||||
# dir (QWEN_HOSTED_CONFIG_DIR, $HOME/.claude-personal) and MODEL (sonnet), so it uses that subscription.
|
||||
# DIR, which must be new or empty, gets for each run <name>.out and <name>.err (the tool's output),
|
||||
# <name>.patch (its diff, new files included) and <name>.server.log (the server log lines written during the
|
||||
# run); summary.tsv (name, exit code, wall seconds); and rss.log (llama-server's memory every 30 seconds).
|
||||
# <name>.patch (its diff, new files included) and summary.tsv (name, exit code, wall seconds); and, only when a
|
||||
# llama-server runs on this host with a pid file and a log under QWEN_HOME (not the ygg-models.sh container, which has
|
||||
# neither: read `podman logs` through ygg-engine.sh), <name>.server.log and rss.log (its memory every 30 seconds).
|
||||
# Both are skipped otherwise.
|
||||
# The worktrees stay in DIR/wt-<name> for review; the cleanup commands are printed at the end.
|
||||
# QWEN_COMPARE_TIMEOUT caps each run (1800 seconds). QWEN_COMPARE_OPENCODE, QWEN_COMPARE_QWEN_CLAUDE and
|
||||
# QWEN_CLAUDE_BIN override the three commands.
|
||||
|
||||
+35
-27
@@ -1,39 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared defaults and helpers for the qwen-* scripts, which run Qwen 3.8 27B on the local GPU
|
||||
# (docs/5-research/2026-10-19-local-qwen-design.md). Source this file; do not run it.
|
||||
# Shared defaults and helpers for the qwen-* scripts, which use the local model server
|
||||
# (docs/5-research/2026-10-19-local-qwen-design.md; the server is scripts/ygg-models.sh, started from the
|
||||
# operator's model list). Source this file; do not run it.
|
||||
# The caller's values win over these defaults:
|
||||
# QWEN_HOME model files, the llama.cpp build, logs and run state ($HOME/.local/share/qwen-local)
|
||||
# QWEN_PORT the server's port on 127.0.0.1 (8088)
|
||||
# QWEN_CTX the context size in tokens (102400)
|
||||
# QWEN_ALIAS the model name the server answers to (qwen3.8-27b)
|
||||
# QWEN_STATE, written by `qwen-local.sh start` and removed by `stop`, holds the running server's
|
||||
# QWEN_PORT and QWEN_CTX, so that the launchers use the values the server really has.
|
||||
# QWEN_HOME logs and run state of the older measurement scripts ($HOME/.local/share/qwen-local)
|
||||
# QWEN_CTX the context size in tokens (102400); keep it equal to `context` in the model list
|
||||
# QWEN_ALIAS the model's name in the list (qwen3.8-27b); the name OpenCode and Claude Code ask for
|
||||
# QWEN_MODELS_SH the script that reports the server (ygg-models.sh next to this file)
|
||||
# QWEN_MODELS the model list, passed as --models (ygg-models.sh's default list when unset)
|
||||
# QWEN_FOLDER the factory folder (factory.yaml), passed as --folder (ygg-models.sh's default, ~/ygg-it, when unset)
|
||||
# The server's URL comes from `ygg-models.sh status`, which prints `running on <URL>, loaded: <name|none>`.
|
||||
|
||||
QWEN_HOME="${QWEN_HOME:-$HOME/.local/share/qwen-local}"
|
||||
QWEN_PORT="${QWEN_PORT:-8088}"
|
||||
QWEN_CTX="${QWEN_CTX:-102400}"
|
||||
QWEN_ALIAS="${QWEN_ALIAS:-qwen3.8-27b}"
|
||||
QWEN_STATE="$QWEN_HOME/run/server.env"
|
||||
QWEN_MODELS_SH="${QWEN_MODELS_SH:-$(dirname "${BASH_SOURCE[0]}")/ygg-models.sh}"
|
||||
|
||||
# qwen_load_state: take QWEN_PORT and QWEN_CTX from the running server's state file, if there is one.
|
||||
# The file is read as data, never sourced.
|
||||
qwen_load_state() {
|
||||
local v
|
||||
[ -f "$QWEN_STATE" ] || return 0
|
||||
v="$(sed -n 's/^QWEN_PORT=\([0-9][0-9]*\)$/\1/p' "$QWEN_STATE" | head -n 1)"
|
||||
[ -z "$v" ] || QWEN_PORT="$v"
|
||||
v="$(sed -n 's/^QWEN_CTX=\([0-9][0-9]*\)$/\1/p' "$QWEN_STATE" | head -n 1)"
|
||||
[ -z "$v" ] || QWEN_CTX="$v"
|
||||
# qwen_status: run `ygg-models.sh status` (with --models "$QWEN_MODELS" and --folder "$QWEN_FOLDER" when set), print its line, pass its exit code on.
|
||||
# Its stderr is not hidden: an engine that does not answer says why.
|
||||
qwen_status() {
|
||||
local args=()
|
||||
[ -z "${QWEN_MODELS:-}" ] || args+=(--models "$QWEN_MODELS")
|
||||
[ -z "${QWEN_FOLDER:-}" ] || args+=(--folder "$QWEN_FOLDER")
|
||||
"$QWEN_MODELS_SH" "${args[@]}" status
|
||||
}
|
||||
|
||||
qwen_url() { echo "http://127.0.0.1:$QWEN_PORT"; }
|
||||
|
||||
qwen_healthy() { curl -fsS -m 2 "$(qwen_url)/health" >/dev/null 2>&1; }
|
||||
# qwen_url: print the running server's URL; exit 1 when it does not run.
|
||||
qwen_url() {
|
||||
local line
|
||||
line="$(qwen_status 2>/dev/null)" || return 1
|
||||
case "$line" in "running on "*) ;; *) return 1 ;; esac
|
||||
line="${line#running on }"
|
||||
echo "${line%%,*}"
|
||||
}
|
||||
|
||||
# qwen_require_server: exit 2 with a hint when the server is not running (status exit 3), and exit 2 with the
|
||||
# status script's own message when the check itself failed (the engine does not answer, the list is wrong).
|
||||
qwen_require_server() {
|
||||
qwen_load_state
|
||||
if ! qwen_healthy; then
|
||||
echo "the local model server is not running on $(qwen_url); start it with scripts/qwen-local.sh start" >&2
|
||||
exit 2
|
||||
fi
|
||||
local rc=0
|
||||
qwen_status >/dev/null || rc=$?
|
||||
case "$rc" in
|
||||
0) ;;
|
||||
3) echo "the local model server is not running; start it with scripts/ygg-models.sh start" >&2; exit 2 ;;
|
||||
*) echo "the local model server could not be checked (ygg-models.sh status exit $rc; see above)" >&2; exit 2 ;;
|
||||
esac
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Start, stop and check llama-server serving Qwen 3.8 27B on 127.0.0.1, all of it on the GPU
|
||||
# (docs/5-research/2026-10-19-local-qwen-design.md; setup in docs/5-research/2026-10-19-local-qwen-notes.md).
|
||||
# qwen-local.sh start start the server in the background; wait until it answers /health
|
||||
# qwen-local.sh stop stop it (TERM, then KILL after 30 seconds)
|
||||
# qwen-local.sh status exit 0 and print the URL when it runs, exit 3 when it does not
|
||||
# qwen-local.sh cmd print the server command line, and run nothing
|
||||
# qwen-local.sh template save the running model's chat template, changed so that a system message
|
||||
# after the first one is rendered instead of refused (Claude Code sends one,
|
||||
# for hook context); start uses the saved file from then on
|
||||
# Environment, besides qwen-lib.sh's QWEN_HOME, QWEN_PORT, QWEN_CTX and QWEN_ALIAS:
|
||||
# QWEN_LLAMA_SERVER the binary ($QWEN_HOME/llama.cpp/build/bin/llama-server)
|
||||
# QWEN_MODEL the GGUF file ($QWEN_HOME/models/model.gguf)
|
||||
# QWEN_EFFORT reasoning effort: low (default), medium, xhigh, or off (no thinking)
|
||||
# QWEN_MTP_ARGS the multi-token prediction flags, split on spaces; empty turns MTP off. The default
|
||||
# drafts with $QWEN_HOME/models/mtp.gguf, all on the GPU
|
||||
# QWEN_CACHE_RAM the most MiB of system RAM for llama-server's prompt cache (2048). Its own default,
|
||||
# 8192, grew past WSL's 12 GB memory cap in an agent session, and the kernel's
|
||||
# out-of-memory killer stopped the server
|
||||
# QWEN_CTX_CHECKPOINTS context checkpoints kept per slot (4). This hybrid model saves its recurrent
|
||||
# state in system RAM at each checkpoint; the default, 32, grew the server by about
|
||||
# 4 GB over a long session. Fewer means more prompt re-reading after a rewind
|
||||
# QWEN_START_TIMEOUT seconds to wait for /health (300: loading 17 GB takes a while)
|
||||
# QWEN_CHAT_TEMPLATE the saved chat template ($QWEN_HOME/models/chat-template.jinja), used when it exists
|
||||
# The log is $QWEN_HOME/logs/server.log; the previous one is kept as server.log.1.
|
||||
set -euo pipefail
|
||||
# shellcheck source-path=SCRIPTDIR source=qwen-lib.sh
|
||||
. "$(dirname "${BASH_SOURCE[0]}")/qwen-lib.sh"
|
||||
usage() { echo "usage: qwen-local.sh start|stop|status|cmd|template" >&2; exit 2; }
|
||||
[ $# -eq 1 ] || usage
|
||||
|
||||
bin="${QWEN_LLAMA_SERVER:-$QWEN_HOME/llama.cpp/build/bin/llama-server}"
|
||||
model="${QWEN_MODEL:-$QWEN_HOME/models/model.gguf}"
|
||||
effort="${QWEN_EFFORT:-low}"
|
||||
mtp="${QWEN_MTP_ARGS---spec-type draft-mtp -md $QWEN_HOME/models/mtp.gguf -ngld all -ctkd q8_0 -ctvd q8_0}"
|
||||
timeout="${QWEN_START_TIMEOUT:-300}"
|
||||
tplf="${QWEN_CHAT_TEMPLATE:-$QWEN_HOME/models/chat-template.jinja}"
|
||||
pidf="$QWEN_HOME/run/server.pid"
|
||||
log="$QWEN_HOME/logs/server.log"
|
||||
|
||||
case "$effort" in
|
||||
off) reasoning=(--reasoning off) ;;
|
||||
low | medium | xhigh) reasoning=(--reasoning-effort "$effort") ;;
|
||||
*) echo "QWEN_EFFORT is '$effort'; use one of low, medium, xhigh, off" >&2; exit 2 ;;
|
||||
esac
|
||||
args=(-m "$model" --alias "$QWEN_ALIAS" --host 127.0.0.1 --port "$QWEN_PORT" -ngl 999 -c "$QWEN_CTX"
|
||||
-fa on --cache-type-k q8_0 --cache-type-v q8_0 --cache-ram "${QWEN_CACHE_RAM:-2048}"
|
||||
--ctx-checkpoints "${QWEN_CTX_CHECKPOINTS:-4}" --parallel 1 --jinja
|
||||
--temp 1.0 --top-p 0.95 --top-k 20 --min-p 0 "${reasoning[@]}")
|
||||
[ ! -f "$tplf" ] || args+=(--chat-template-file "$tplf")
|
||||
# shellcheck disable=SC2206 # QWEN_MTP_ARGS is a list of flags, split on spaces on purpose
|
||||
[ -z "$mtp" ] || args+=($mtp)
|
||||
|
||||
# running: print the pid of our llama-server, if the pid file names one that is alive. A pid file
|
||||
# whose process is gone, or is some other program (the pid was reused), is stale and is removed.
|
||||
running() {
|
||||
local pid
|
||||
[ -f "$pidf" ] || return 1
|
||||
pid="$(cat "$pidf")"
|
||||
if [[ "$pid" =~ ^[0-9]+$ ]] && kill -0 "$pid" 2>/dev/null && tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null | grep -q llama-server; then
|
||||
echo "$pid"
|
||||
return 0
|
||||
fi
|
||||
echo "removing a stale pid file ($pidf: $pid)" >&2
|
||||
rm -f "$pidf" "$QWEN_STATE"
|
||||
return 1
|
||||
}
|
||||
|
||||
quote() {
|
||||
local a out=()
|
||||
for a in "$@"; do
|
||||
if [[ "$a" =~ ^[A-Za-z0-9_./:=+,-]+$ ]]; then out+=("$a"); else out+=("'$a'"); fi
|
||||
done
|
||||
echo "${out[*]}"
|
||||
}
|
||||
|
||||
stop_pid() {
|
||||
local pid="$1" i
|
||||
kill "$pid" 2>/dev/null || return 0
|
||||
for ((i = 0; i < 30; i++)); do
|
||||
kill -0 "$pid" 2>/dev/null || return 0
|
||||
sleep 1
|
||||
done
|
||||
kill -KILL "$pid" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# The model's template refuses a system message after the first. This renders it as a system turn.
|
||||
fix_template='
|
||||
import json, sys
|
||||
t = json.load(sys.stdin)["chat_template"]
|
||||
old = "{{- raise_exception(\x27System message must be at the beginning.\x27) }}"
|
||||
if t.count(old) != 1:
|
||||
sys.exit("did not find the line that refuses later system messages in the chat template; check the template by hand")
|
||||
open(sys.argv[1], "w").write(t.replace(old, "{{- \x27<|im_start|>system\\n\x27 + content + \x27<|im_end|>\\n\x27 }}"))
|
||||
'
|
||||
|
||||
case "$1" in
|
||||
cmd) quote "$bin" "${args[@]}" ;;
|
||||
template)
|
||||
qwen_require_server
|
||||
mkdir -p "$(dirname "$tplf")"
|
||||
if ! curl -fsS -m 10 "$(qwen_url)/props" | python3 -I -c "$fix_template" "$tplf.tmp"; then rm -f "$tplf.tmp"; exit 1; fi
|
||||
mv -f "$tplf.tmp" "$tplf"
|
||||
echo "saved $tplf; restart the server to use it: qwen-local.sh stop && qwen-local.sh start"
|
||||
;;
|
||||
status)
|
||||
qwen_load_state
|
||||
if running >/dev/null; then echo "running on $(qwen_url)"; else echo "not running"; exit 3; fi
|
||||
;;
|
||||
stop)
|
||||
if pid="$(running)"; then
|
||||
stop_pid "$pid"
|
||||
rm -f "$pidf" "$QWEN_STATE"
|
||||
echo "stopped"
|
||||
else
|
||||
echo "not running"
|
||||
fi
|
||||
;;
|
||||
start)
|
||||
if running >/dev/null; then qwen_load_state; echo "already running on $(qwen_url)"; exit 0; fi
|
||||
[ -f "$model" ] || { echo "the model $model does not exist; see the setup in docs/5-research/2026-10-19-local-qwen-notes.md" >&2; exit 2; }
|
||||
[ -x "$bin" ] || { echo "llama-server $bin does not exist or is not executable" >&2; exit 2; }
|
||||
if qwen_healthy; then echo "port $QWEN_PORT already answers on $(qwen_url), and it is not our server; stop that program or set QWEN_PORT" >&2; exit 2; fi
|
||||
mkdir -p "$QWEN_HOME/run" "$QWEN_HOME/logs"
|
||||
[ ! -f "$log" ] || mv -f "$log" "$log.1"
|
||||
nohup "$bin" "${args[@]}" >"$log" 2>&1 &
|
||||
pid=$!
|
||||
echo "$pid" > "$pidf"
|
||||
printf 'QWEN_PORT=%s\nQWEN_CTX=%s\n' "$QWEN_PORT" "$QWEN_CTX" > "$QWEN_STATE"
|
||||
for ((i = 0; i < timeout; i++)); do
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
rm -f "$pidf" "$QWEN_STATE"
|
||||
echo "llama-server exited while starting; the end of $log:" >&2
|
||||
tail -n 20 "$log" >&2
|
||||
exit 1
|
||||
fi
|
||||
if qwen_healthy; then echo "ready on $(qwen_url) (pid $pid, log $log)"; exit 0; fi
|
||||
sleep 1
|
||||
done
|
||||
stop_pid "$pid"
|
||||
rm -f "$pidf" "$QWEN_STATE"
|
||||
echo "llama-server did not become ready within $timeout seconds; stopped it. The end of $log:" >&2
|
||||
tail -n 20 "$log" >&2
|
||||
exit 1
|
||||
;;
|
||||
*) usage ;;
|
||||
esac
|
||||
@@ -29,7 +29,7 @@ def post(path, body, timeout=1800):
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return json.load(r)
|
||||
except (urllib.error.URLError, ConnectionError) as e:
|
||||
sys.exit(f"the local model server does not answer on {URL} ({e}); start it with scripts/qwen-local.sh start")
|
||||
sys.exit(f"the local model server does not answer on {URL} ({e}); start it with scripts/ygg-models.sh start")
|
||||
|
||||
|
||||
def ntokens(text):
|
||||
@@ -78,7 +78,7 @@ def rss_gb(pid):
|
||||
|
||||
def growth(a):
|
||||
if not os.path.exists(f"/proc/{a.pid}/status"):
|
||||
print(f"no process {a.pid}; pass the pid of llama-server ($QWEN_HOME/run/server.pid)", file=sys.stderr)
|
||||
print(f"no process {a.pid}; pass the host pid of a llama-server process; the ygg-models.sh container's process has a pid the host shows only in its own process list", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
src = open(a.source_file).read()
|
||||
msgs = []
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run OpenCode against the local Qwen 3.8 27B server, and nothing else
|
||||
# (docs/5-research/2026-10-19-local-qwen-design.md). Start the server first: qwen-local.sh start.
|
||||
# (docs/5-research/2026-10-19-local-qwen-design.md). Start the server first: ygg-models.sh start.
|
||||
# qwen-opencode.sh [OPENCODE ARGS...] for example: qwen-opencode.sh, or qwen-opencode.sh run "..."
|
||||
# The configuration goes in through OPENCODE_CONFIG_CONTENT, with the running server's port and
|
||||
# context. Only the local provider is enabled, and the cloud credentials of the caller's shell are
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
# An example model list for scripts/ygg-models.sh and `ygg models`. Copy it to ~/.config/ygg/models.yaml and
|
||||
# change the two folders to absolute paths on your machine. Never commit your own list.
|
||||
# The engine is a Podman machine that sees host files only through the `sandbox.mounts` of factory.yaml, so both
|
||||
# folders must be under a folder of those mounts (for the founder's setup, under ~/ygg-it).
|
||||
server:
|
||||
name: ygg-models # the container's name and its name on the network
|
||||
network: ygg-models # an internal podman network
|
||||
port: 8089 # the gate's port on that network
|
||||
host_port: 8088 # llama-server on 127.0.0.1 of the host
|
||||
weights: /abs/path/to/weights # mounted read-only at /models
|
||||
state: /abs/path/to/state # the preset and the allow file; mounted read-only at /etc/ygg-models
|
||||
models:
|
||||
qwen3.8-27b:
|
||||
file: model.gguf # under weights
|
||||
context: 102400 # the context size; becomes ctx-size in the preset
|
||||
output: 32768 # the most output tokens OpenCode asks for
|
||||
args: # llama-server preset keys, written as given
|
||||
n-gpu-layers: "999"
|
||||
flash-attn: "on"
|
||||
cache-type-k: q8_0
|
||||
cache-type-v: q8_0
|
||||
parallel: "1"
|
||||
jinja: "true"
|
||||
# Sampling: llama-server takes temp, top-k and top-p from the model file (its general.sampling.* keys)
|
||||
# unless a key here sets them, so the list sets only what the file lacks. Add temp, top-p or top-k here to
|
||||
# override the model's own values.
|
||||
min-p: "0" # Qwen's recommendation; the model file has no min_p, and llama-server's own default is 0.05
|
||||
reasoning-effort: low
|
||||
# Out-of-memory-safe values for a WSL2 machine with limited RAM: a small prompt cache and few checkpoints.
|
||||
cache-ram: "2048"
|
||||
ctx-checkpoints: "4"
|
||||
# MTP: the draft model speeds up decoding. The draft file sits next to the model, under weights.
|
||||
spec-type: draft-mtp
|
||||
model-draft: /models/mtp.gguf
|
||||
n-gpu-layers-draft: all
|
||||
cache-type-k-draft: q8_0
|
||||
cache-type-v-draft: q8_0
|
||||
# Claude Code sends a later system message that the model's own template refuses. `scripts/ygg-models.sh
|
||||
# template NAME` writes a fixed template to weights/chat-template.jinja; then uncomment the next line, and
|
||||
# stop and start the server.
|
||||
# chat-template-file: /models/chat-template.jinja
|
||||
price: {input: 0.10, output: 0.40} # example values: OpenRouter's price, dollars per million tokens
|
||||
+1
-1
@@ -9,7 +9,7 @@ privacy: strict
|
||||
roles:
|
||||
planner: { agent: @PLANNER_AGENT@, model: @PLANNER_MODEL@ }
|
||||
implementer: { agent: @IMPLEMENTER_AGENT@, model: @IMPLEMENTER_MODEL@ }
|
||||
reviewer: { agent: @REVIEWER_AGENT@, model: @REVIEWER_MODEL@, reasoning: @REVIEWER_REASONING@ }
|
||||
reviewer: { agent: @REVIEWER_AGENT@, model: @REVIEWER_MODEL@@REVIEWER_REASONING@ }
|
||||
retrospective: { agent: @RETRO_AGENT@, model: @RETRO_MODEL@ }
|
||||
budget:
|
||||
per_run: { tokens: @RUN_TOKENS@, turns: @RUN_TURNS@@RUN_USD@ }
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# the linter cannot see them used.
|
||||
# shellcheck disable=SC2016,SC2034,SC2329
|
||||
# Tests for qwen-claude.sh. scripts/tests/qwen-claude.test.sh
|
||||
# A fake `claude` and a fake `curl` stand in for the real tools: no server, no account, no network.
|
||||
# A fake `claude` and a fake ygg-models.sh stand in for the real tools: no server, no account, no network.
|
||||
set -uo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
S="$HERE/.."
|
||||
@@ -38,13 +38,19 @@ cat > "$T/bin/claude" <<EOF2
|
||||
echo "\${CLAUDE_CONFIG_DIR:-none} \$*" >> "$T/calls"
|
||||
env > "$T/env"
|
||||
EOF2
|
||||
cat > "$T/bin/curl" <<EOF2
|
||||
# A fake ygg-models.sh (QWEN_MODELS_SH) stands in for the real one: `status` answers while $T/healthy exists.
|
||||
cat > "$T/ygg-models.sh" <<EOF2
|
||||
#!/usr/bin/env bash
|
||||
[ -e "$T/healthy" ] || exit 7
|
||||
echo '{"status":"ok"}'
|
||||
echo "\$*" > "$T/models.args"
|
||||
[ "\$1" != --models ] || shift 2
|
||||
[ "\$1" = status ] || exit 64
|
||||
[ ! -e "$T/engine-down" ] || { echo "ygg-engine: the engine socket is not there" >&2; exit 2; }
|
||||
[ -e "$T/healthy" ] || { echo "not running"; exit 3; }
|
||||
echo "running on http://127.0.0.1:8088, loaded: none"
|
||||
EOF2
|
||||
chmod +x "$T/bin/claude" "$T/bin/curl"
|
||||
export PATH="$T/bin:$PATH" QWEN_HOME="$T/qh" QWEN_CLAUDE_BIN="$T/bin/claude"
|
||||
chmod +x "$T/ygg-models.sh"
|
||||
chmod +x "$T/bin/claude"
|
||||
export PATH="$T/bin:$PATH" QWEN_HOME="$T/qh" QWEN_MODELS_SH="$T/ygg-models.sh" QWEN_CLAUDE_BIN="$T/bin/claude"
|
||||
C="$S/qwen-claude.sh"
|
||||
|
||||
run bash "$C" -p hi
|
||||
@@ -76,7 +82,15 @@ check "init again keeps an existing .claude.json and the skills link" \
|
||||
|
||||
run bash "$C" -p hi
|
||||
check "run with the server down: refuses with the start hint; claude does not run" \
|
||||
eval '[ $rc = 2 ] && has "qwen-local.sh start" && [ ! -e "$T/calls" ]'
|
||||
eval '[ $rc = 2 ] && has "ygg-models.sh start" && [ ! -e "$T/calls" ]'
|
||||
touch "$T/engine-down"; run bash "$C" -p hi
|
||||
check "engine check failed: its message is passed through, and it is not reported as 'not running'" \
|
||||
eval '[ $rc = 2 ] && has "engine socket is not there" && ! has "ygg-models.sh start" && [ ! -e "$T/calls" ]'
|
||||
rm -f "$T/engine-down"
|
||||
QWEN_MODELS="$T/my models.yaml" run bash "$C" -p hi
|
||||
check "QWEN_MODELS is passed to ygg-models.sh as --models" eval 'grep -q -F -- "--models $T/my models.yaml status" "$T/models.args"'
|
||||
QWEN_FOLDER="$T/my folder" run bash "$C" -p hi
|
||||
check "QWEN_FOLDER is passed to ygg-models.sh as --folder" eval 'grep -q -F -- "--folder $T/my folder status" "$T/models.args"'
|
||||
|
||||
touch "$T/healthy"
|
||||
QWEN_CLAUDE_DIR="$HOME/.claude" run bash "$C" -p hi
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Each check is a condition string run by eval: $rc, $took and the helpers are read there, so
|
||||
# the linter cannot see them used.
|
||||
# shellcheck disable=SC2016,SC2034,SC2329
|
||||
# Tests for qwen-local.sh and qwen-lib.sh. scripts/tests/qwen-local.test.sh
|
||||
# A fake `llama-server` and a fake `curl` stand in for the real tools: no GPU, no model, no network.
|
||||
set -uo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
S="$HERE/.."
|
||||
T="$(mktemp -d)"
|
||||
cleanup() { pkill -f "$T/bin/llama-server" 2>/dev/null; [ -z "${SLEEPER:-}" ] || kill "$SLEEPER" 2>/dev/null; rm -rf -- "$T"; }
|
||||
trap cleanup EXIT
|
||||
fail=0
|
||||
check() {
|
||||
local d="$1"; shift
|
||||
if "$@"; then echo "ok $d"; else echo "FAIL $d"; fail=1; [ -z "${VERBOSE:-}" ] || sed 's/^/ | /' "$T/out"; fi
|
||||
}
|
||||
run() { "$@" >"$T/out" 2>&1; rc=$?; }
|
||||
has() { grep -q -F -- "$1" "$T/out"; }
|
||||
export HOME="$T/home"; mkdir -p "$HOME"
|
||||
|
||||
# The fake server writes its arguments, one per line, to $T/args, and marks itself healthy (for the fake
|
||||
# curl) unless FAKE_NEVER_READY is set. FAKE_DIE makes it fail at once, as a model too big for VRAM does.
|
||||
mkdir -p "$T/bin"
|
||||
cat > "$T/bin/llama-server" <<EOF2
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "\$@" > "$T/args"
|
||||
echo "fake llama-server loading"
|
||||
if [ -n "\${FAKE_DIE:-}" ]; then echo "fake: out of memory"; exit 1; fi
|
||||
trap 'rm -f "$T/healthy"; kill \$! 2>/dev/null; exit 0' TERM
|
||||
[ -n "\${FAKE_NEVER_READY:-}" ] || touch "$T/healthy"
|
||||
sleep 300 & wait
|
||||
EOF2
|
||||
# The fake curl answers /props from $T/props, and anything else with a health answer.
|
||||
cat > "$T/bin/curl" <<EOF2
|
||||
#!/usr/bin/env bash
|
||||
[ -e "$T/healthy" ] || exit 7
|
||||
case "\${*: -1}" in */props) cat "$T/props" ;; *) echo '{"status":"ok"}' ;; esac
|
||||
EOF2
|
||||
chmod +x "$T/bin/llama-server" "$T/bin/curl"
|
||||
touch "$T/model.gguf"
|
||||
export PATH="$T/bin:$PATH" QWEN_HOME="$T/qh" QWEN_LLAMA_SERVER="$T/bin/llama-server" QWEN_MODEL="$T/model.gguf" \
|
||||
QWEN_START_TIMEOUT=3
|
||||
L="$S/qwen-local.sh"
|
||||
pidf="$T/qh/run/server.pid" envf="$T/qh/run/server.env"
|
||||
nservers() { pgrep -fc "$T/bin/llama-server"; }
|
||||
|
||||
# cmd
|
||||
run bash "$L" cmd
|
||||
check "cmd: binds 127.0.0.1:8088 with a 102400 context, a q8_0 KV cache, one slot and low effort" \
|
||||
eval '[ $rc = 0 ] && has "--host 127.0.0.1" && has "--port 8088" && has "-c 102400" && has "--cache-type-k q8_0" && has "--cache-type-v q8_0" && has "--parallel 1" && has "--reasoning-effort low"'
|
||||
check "cmd: the prompt cache in system RAM is capped at 2048 MiB, so it fits WSL's memory cap" eval 'has "--cache-ram 2048"'
|
||||
check "cmd: the model's context checkpoints in system RAM are limited to 4" eval 'has "--ctx-checkpoints 4"'
|
||||
QWEN_CACHE_RAM=0 QWEN_CTX_CHECKPOINTS=8 run bash "$L" cmd
|
||||
check "cmd: QWEN_CACHE_RAM and QWEN_CTX_CHECKPOINTS set the limits" eval 'has "--cache-ram 0" && has "--ctx-checkpoints 8"'
|
||||
run bash "$L" cmd
|
||||
check "cmd: MTP is on by default, with the draft file from QWEN_HOME on the GPU" \
|
||||
eval 'has "--spec-type draft-mtp -md $T/qh/models/mtp.gguf -ngld all -ctkd q8_0 -ctvd q8_0"'
|
||||
QWEN_EFFORT=off run bash "$L" cmd
|
||||
check "cmd: effort off turns thinking off" eval 'has "--reasoning off" && ! has "--reasoning-effort"'
|
||||
QWEN_EFFORT=high run bash "$L" cmd
|
||||
check "cmd: an unknown effort is a usage error that names the allowed values" \
|
||||
eval '[ $rc = 2 ] && has QWEN_EFFORT && has "low, medium, xhigh, off"'
|
||||
QWEN_MTP_ARGS="--spec-a x" run bash "$L" cmd
|
||||
check "cmd: QWEN_MTP_ARGS ends the argument list" eval 'grep -Eq -- "--spec-a x ?\$" "$T/out"'
|
||||
QWEN_MTP_ARGS="" run bash "$L" cmd
|
||||
check "cmd: an empty QWEN_MTP_ARGS turns MTP off" eval '! has "--spec-a" && ! has "--spec-type"'
|
||||
|
||||
tpl="$T/qh/models/chat-template.jinja"
|
||||
mkdir -p "$T/qh/models"; echo x > "$tpl"
|
||||
run bash "$L" cmd
|
||||
check "cmd: a saved chat template is passed to the server" eval 'has "--chat-template-file $tpl"'
|
||||
rm -f "$tpl"
|
||||
run bash "$L" cmd
|
||||
check "cmd: without a saved chat template, the model's own is used" eval '! has "--chat-template-file"'
|
||||
|
||||
# template
|
||||
run bash "$L" template
|
||||
check "template: needs the server running" eval '[ $rc = 2 ] && has "server is not running" && [ ! -e "$tpl" ]'
|
||||
touch "$T/healthy"
|
||||
python3 -I -c 'import json,sys; print(json.dumps({"chat_template": "{%- if a %}\n {{- raise_exception(\x27System message must be at the beginning.\x27) }}\n{%- endif %}"}))' > "$T/props"
|
||||
run bash "$L" template
|
||||
check "template: saves the model's template with later system messages rendered as system turns" \
|
||||
eval '[ $rc = 0 ] && has "$tpl" && ! grep -q raise_exception "$tpl" && grep -qF "<|im_start|>system\\n" "$tpl" && grep -qF " + content + " "$tpl" && grep -qF "{%- if a %}" "$tpl"'
|
||||
rm -f "$tpl"; echo '{"chat_template": "no such line"}' > "$T/props"
|
||||
run bash "$L" template
|
||||
check "template: a template without the expected line is refused, and nothing is saved" \
|
||||
eval '[ $rc = 1 ] && has "did not find" && [ ! -e "$tpl" ]'
|
||||
rm -f "$T/healthy" "$T/props"
|
||||
|
||||
# start
|
||||
QWEN_MODEL="$T/missing.gguf" run bash "$L" start
|
||||
check "start: a missing model fails with its path, and starts nothing" \
|
||||
eval '[ $rc = 2 ] && has "$T/missing.gguf" && [ ! -e "$T/args" ]'
|
||||
|
||||
run bash "$L" start
|
||||
check "start: waits for health, says where it is ready, and records the pid and the state" \
|
||||
eval '[ $rc = 0 ] && has "ready on http://127.0.0.1:8088" && kill -0 "$(cat "$pidf")" && grep -qx -- "-m" "$T/args" && grep -qx -- "$T/model.gguf" "$T/args" && grep -qx QWEN_PORT=8088 "$envf" && grep -qx QWEN_CTX=102400 "$envf"'
|
||||
|
||||
run bash "$L" start
|
||||
check "start: a second start while running says so and starts no second server" \
|
||||
eval '[ $rc = 0 ] && has "already running" && [ "$(nservers)" = 1 ]'
|
||||
|
||||
run bash "$L" status
|
||||
check "status: running exits 0 with the URL" eval '[ $rc = 0 ] && has running && has "http://127.0.0.1:8088"'
|
||||
|
||||
run bash "$L" stop
|
||||
check "stop: the server is gone and the pid and state files are removed" \
|
||||
eval '[ $rc = 0 ] && [ "$(nservers)" = 0 ] && [ ! -e "$pidf" ] && [ ! -e "$envf" ]'
|
||||
|
||||
run bash "$L" stop
|
||||
check "stop: not running is not an error" eval '[ $rc = 0 ] && has "not running"'
|
||||
|
||||
run bash "$L" status
|
||||
check "status: not running exits 3" eval '[ $rc = 3 ] && has "not running"'
|
||||
|
||||
FAKE_DIE=1 run bash "$L" start
|
||||
check "start: a server that exits while loading fails at once and shows the log tail" \
|
||||
eval '[ $rc = 1 ] && has "exited while starting" && has "fake: out of memory" && [ ! -e "$pidf" ] && [ ! -e "$envf" ]'
|
||||
|
||||
SECONDS=0
|
||||
FAKE_NEVER_READY=1 run bash "$L" start
|
||||
took=$SECONDS
|
||||
check "start: a server that never gets healthy is stopped after the timeout" \
|
||||
eval '[ $rc = 1 ] && has "did not become ready" && [ "$took" -le 8 ] && [ "$(nservers)" = 0 ] && [ ! -e "$pidf" ]'
|
||||
|
||||
rm -f "$T/args"; touch "$T/healthy"
|
||||
run bash "$L" start
|
||||
check "start: another program answering on the port is refused, and nothing starts" \
|
||||
eval '[ $rc = 2 ] && has "port 8088 already answers" && [ ! -e "$T/args" ]'
|
||||
rm -f "$T/healthy"
|
||||
|
||||
# A stale pid file: its process is gone (after a crash or wsl --shutdown).
|
||||
mkdir -p "$T/qh/run"; sh -c 'exit 0' & dead=$!; wait "$dead"
|
||||
echo "$dead" > "$pidf"; printf 'QWEN_PORT=8088\nQWEN_CTX=102400\n' > "$envf"
|
||||
run bash "$L" status
|
||||
check "status: a stale pid file means not running, and is cleaned up" \
|
||||
eval '[ $rc = 3 ] && has "not running" && [ ! -e "$pidf" ] && [ ! -e "$envf" ]'
|
||||
|
||||
# A reused pid: the pid file names a live process that is not llama-server.
|
||||
sleep 60 & SLEEPER=$!
|
||||
echo "$SLEEPER" > "$pidf"
|
||||
run bash "$L" stop
|
||||
check "stop: a pid that is not llama-server is never killed; the pid file is removed as stale" \
|
||||
eval '[ $rc = 0 ] && has stale && kill -0 "$SLEEPER" && [ ! -e "$pidf" ]'
|
||||
|
||||
# qwen_load_state
|
||||
printf 'QWEN_PORT=9000\nQWEN_CTX=65536\n' > "$envf"
|
||||
run bash -c '. "$1/qwen-lib.sh"; qwen_load_state; qwen_url; echo "ctx=$QWEN_CTX"' _ "$S"
|
||||
check "qwen_load_state: the running server's port and context win over the defaults" \
|
||||
eval 'has "http://127.0.0.1:9000" && has "ctx=65536"'
|
||||
|
||||
exit $fail
|
||||
@@ -72,6 +72,6 @@ check "growth: a pid that does not exist is an error" eval '[ $rc = 2 ] && has "
|
||||
|
||||
QWEN_URL="http://127.0.0.1:1" run python3 -I "$M" speed --runs 1 "$T/prompt.txt"
|
||||
check "speed: a server that does not answer is a clear error, not a traceback" \
|
||||
eval '[ $rc = 2 ] && has "qwen-local.sh start" && ! has Traceback'
|
||||
eval '[ $rc = 2 ] && has "ygg-models.sh start" && ! has Traceback'
|
||||
|
||||
exit $fail
|
||||
@@ -3,7 +3,7 @@
|
||||
# the linter cannot see them used.
|
||||
# shellcheck disable=SC2016,SC2034,SC2329
|
||||
# Tests for qwen-opencode.sh. scripts/tests/qwen-opencode.test.sh
|
||||
# A fake `opencode` and a fake `curl` stand in for the real tools: no server, no network.
|
||||
# A fake `opencode` and a fake ygg-models.sh stand in for the real tools: no server, no network.
|
||||
set -uo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
S="$HERE/.."
|
||||
@@ -28,22 +28,36 @@ cat > "$T/bin/opencode" <<EOF2
|
||||
printf '%s\n' "\$@" > "$T/args"
|
||||
env > "$T/env"
|
||||
EOF2
|
||||
cat > "$T/bin/curl" <<EOF2
|
||||
# A fake ygg-models.sh (QWEN_MODELS_SH) stands in for the real one: `status` answers while $T/healthy exists.
|
||||
cat > "$T/ygg-models.sh" <<EOF2
|
||||
#!/usr/bin/env bash
|
||||
[ -e "$T/healthy" ] || exit 7
|
||||
echo '{"status":"ok"}'
|
||||
echo "\$*" > "$T/models.args"
|
||||
[ "\$1" != --models ] || shift 2
|
||||
[ "\$1" = status ] || exit 64
|
||||
[ ! -e "$T/engine-down" ] || { echo "ygg-engine: the engine socket is not there" >&2; exit 2; }
|
||||
[ -e "$T/healthy" ] || { echo "not running"; exit 3; }
|
||||
echo "running on http://127.0.0.1:9000, loaded: none"
|
||||
EOF2
|
||||
chmod +x "$T/bin/opencode" "$T/bin/curl"
|
||||
export PATH="$T/bin:$PATH" QWEN_HOME="$T/qh"
|
||||
chmod +x "$T/ygg-models.sh"
|
||||
chmod +x "$T/bin/opencode"
|
||||
export PATH="$T/bin:$PATH" QWEN_HOME="$T/qh" QWEN_MODELS_SH="$T/ygg-models.sh"
|
||||
O="$S/qwen-opencode.sh"
|
||||
|
||||
run bash "$O" run hi
|
||||
check "server down: refuses with the start hint, and opencode does not run" \
|
||||
eval '[ $rc = 2 ] && has "qwen-local.sh start" && [ ! -e "$T/args" ]'
|
||||
eval '[ $rc = 2 ] && has "ygg-models.sh start" && [ ! -e "$T/args" ]'
|
||||
touch "$T/engine-down"; run bash "$O" run hi
|
||||
check "engine check failed: its message is passed through, and it is not reported as 'not running'" \
|
||||
eval '[ $rc = 2 ] && has "engine socket is not there" && ! has "ygg-models.sh start" && [ ! -e "$T/args" ]'
|
||||
rm -f "$T/engine-down"
|
||||
QWEN_MODELS="$T/my models.yaml" run bash "$O" run hi
|
||||
check "QWEN_MODELS is passed to ygg-models.sh as --models" eval 'grep -q -F -- "--models $T/my models.yaml status" "$T/models.args"'
|
||||
QWEN_FOLDER="$T/my folder" run bash "$O" run hi
|
||||
check "QWEN_FOLDER is passed to ygg-models.sh as --folder" eval 'grep -q -F -- "--folder $T/my folder status" "$T/models.args"'
|
||||
|
||||
touch "$T/healthy"; mkdir -p "$T/qh/run"; printf 'QWEN_PORT=9000\nQWEN_CTX=65536\n' > "$T/qh/run/server.env"
|
||||
touch "$T/healthy"; export QWEN_CTX=65536
|
||||
ANTHROPIC_API_KEY=a CLAUDE_CODE_OAUTH_TOKEN=b OPENROUTER_API_KEY=c run bash "$O" run "hi there"
|
||||
check "the config points at the running server's port and context, and only at the local provider" \
|
||||
check "the config points at the running server's port and QWEN_CTX, and only at the local provider" \
|
||||
eval '[ $rc = 0 ] && conf "c[\"provider\"][\"qwen-local\"][\"options\"][\"baseURL\"] == \"http://127.0.0.1:9000/v1\"" && conf "c[\"provider\"][\"qwen-local\"][\"models\"][\"qwen3.8-27b\"][\"limit\"][\"context\"] == 65536" && conf "c[\"enabled_providers\"] == [\"qwen-local\"]"'
|
||||
check "the model and the small model are both the local one; sharing and updates are off" \
|
||||
eval 'conf "c[\"model\"] == c[\"small_model\"] == \"qwen-local/qwen3.8-27b\"" && conf "c[\"share\"] == \"disabled\" and c[\"autoupdate\"] is False"'
|
||||
|
||||
Executable
+216
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests for ygg-gpu.sh. scripts/tests/ygg-gpu.test.sh
|
||||
# A fake `wsl.exe` (the machine's distro is a folder of state files), a fake `podman` (machine inspect) and a fake
|
||||
# ygg-engine.sh (YGG_ENGINE_SH) stand in for the real tools; a fake `sudo` logs any call. The whole test runs under
|
||||
# env -i (hermetic.sh): no real wsl.exe, podman or engine is ever called.
|
||||
# shellcheck disable=SC2329 # check calls has and hasnt by name
|
||||
set -uo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source-path=SCRIPTDIR source=hermetic.sh
|
||||
. "$HERE/hermetic.sh"
|
||||
hermetic_enter "$@"
|
||||
S="$HERE/.."
|
||||
trap 'rm -rf -- "$T"' EXIT
|
||||
fail=0
|
||||
check() {
|
||||
local d="$1"; shift
|
||||
if "$@"; then echo "ok $d"; else echo "FAIL $d"; fail=1; [ -z "${VERBOSE:-}" ] || sed 's/^/ | /' "$T/out"; fi
|
||||
}
|
||||
run() {
|
||||
hermetic_path_ok "$PATH" || { echo "FAIL the test's PATH leaves its folder: $PATH"; exit 3; }
|
||||
"$@" >"$T/out" 2>&1; rc=$?
|
||||
}
|
||||
has() { grep -q -F -- "$1" "$T/out"; }
|
||||
hasnt() { ! grep -q -F -- "$1" "$T/out"; }
|
||||
calls() { grep -c -F -- "$1" "$T/wsl.calls" 2>/dev/null || true; }
|
||||
|
||||
M="$T/m" # the machine's distro: toolkit (file), spec (file), fs/ (driver store folders)
|
||||
export FAKE_M="$M" FAKE_LOG="$T/wsl.calls"
|
||||
cat >"$T/bin/wsl.exe" <<'FAKE'
|
||||
#!/bin/bash
|
||||
# wsl.exe -l -q | wsl.exe -d NAME -u root -- sh -s (script on stdin: runs unchanged)
|
||||
# The real wsl.exe hands a command line to the distro's shell, which expands $... before sh runs: the `sh -c CMD` form
|
||||
# loses every $name in CMD (as on the real machine). The fake does the same, for names.
|
||||
if [ "$1" = "-l" ]; then printf 'Ubuntu\r\n%s\r\n' "$(cat "$FAKE_M/distro")" | sed 's/./&\x00/g'; exit 0; fi
|
||||
[ "$1" = "-d" ] && [ "$3" = "-u" ] && [ "$4" = "root" ] && [ "$5" = "--" ] && [ "$6" = "sh" ] || { echo "fake wsl: bad args $*" >&2; exit 64; }
|
||||
case "$7" in
|
||||
-s) [ $# -eq 7 ] || { echo "fake wsl: bad args $*" >&2; exit 64; }; cmd="$(cat)" ;;
|
||||
-c) cmd="$(printf '%s' "$8" | sed -E 's/\$[A-Za-z_][A-Za-z0-9_]*//g')" ;;
|
||||
*) echo "fake wsl: bad args $*" >&2; exit 64 ;;
|
||||
esac
|
||||
echo "$2: $cmd" >>"$FAKE_LOG"
|
||||
case "$cmd" in
|
||||
*"echo toolkit"*) # the in-distro probe: run it for real against the fake distro's files
|
||||
[ -z "$FAKE_WSL_FAIL" ] || { echo "wsl: the distro does not start" >&2; exit 1; }
|
||||
[ -z "$FAKE_WSL_EMPTY" ] || exit 0
|
||||
probe="${cmd//\/etc\/cdi\/nvidia.yaml/$FAKE_M/spec}"
|
||||
probe="${probe//command -v nvidia-ctk >\/dev\/null 2>&1/test -e $FAKE_M/toolkit}"
|
||||
probe="${probe//\[ -d \"\$d\" \]/[ -d \"$FAKE_M/fs/\$(basename \"\$d\")\" ]}"
|
||||
exec sh -c "$probe" ;;
|
||||
*'echo "[stdin'* | *'echo "[cmdline'*) exec sh -c "$cmd" ;;
|
||||
*"dnf install"*) touch "$FAKE_M/toolkit"; exit 0 ;;
|
||||
*"cdi generate"*) { echo "devices:"; for d in "$FAKE_M"/fs/*; do echo " path: /usr/lib/wsl/drivers/$(basename "$d")/x.so"; done; } >"$FAKE_M/spec"; exit 0 ;;
|
||||
"command -v nvidia-ctk"*) [ -e "$FAKE_M/toolkit" ]; exit $? ;;
|
||||
"cat /etc/cdi/nvidia.yaml"*) cat "$FAKE_M/spec"; exit $? ;;
|
||||
"test -d "*) [ -d "$FAKE_M/fs/$(basename "${cmd#test -d }")" ]; exit $? ;;
|
||||
esac
|
||||
echo "fake wsl: unexpected command $cmd" >&2; exit 65
|
||||
FAKE
|
||||
cat >"$T/bin/podman" <<'FAKE'
|
||||
#!/bin/sh
|
||||
echo "podman $*" >>"$FAKE_LOG.podman"
|
||||
[ "$1 $2" = "machine inspect" ] || exit 97
|
||||
[ -z "$FAKE_NOMACHINE" ] || { echo "Error: no machine" >&2; exit 125; }
|
||||
echo "podman-machine-default"
|
||||
FAKE
|
||||
cat >"$T/bin/sudo" <<'FAKE'
|
||||
#!/bin/sh
|
||||
echo "sudo $*" >>"$FAKE_LOG.sudo"; exit 1
|
||||
FAKE
|
||||
cat >"$T/engine.sh" <<'FAKE'
|
||||
#!/bin/bash
|
||||
echo "engine $*" >>"$FAKE_LOG.engine"
|
||||
[ -z "$FAKE_ENGINE_DOWN" ] || { echo "ygg-engine: the engine socket is not there" >&2; exit 2; }
|
||||
if [ "$1" = check ]; then exit 0; fi
|
||||
[ "$1" = run ] || exit 64
|
||||
echo "NVIDIA GeForce RTX 4090, 24564 MiB"
|
||||
FAKE
|
||||
chmod +x "$T/bin/"* "$T/engine.sh"
|
||||
export YGG_ENGINE_SH="$T/engine.sh"
|
||||
|
||||
reset() { # have-toolkit|no-toolkit none|current|stale
|
||||
rm -rf "$M" "$FAKE_LOG" "$FAKE_LOG".*; mkdir -p "$M/fs/drv_a" "$M/fs/drv_b"; : >"$FAKE_LOG"
|
||||
echo podman-machine-default >"$M/distro"
|
||||
[ "$1" = have-toolkit ] && touch "$M/toolkit"
|
||||
case "$2" in
|
||||
current) printf 'devices:\n path: /usr/lib/wsl/drivers/drv_a/x.so\n path: /usr/lib/wsl/drivers/drv_b/y.so\n' >"$M/spec" ;;
|
||||
stale) printf 'devices:\n path: /usr/lib/wsl/drivers/drv_a/x.so\n path: /usr/lib/wsl/drivers/gone_c/y.so\n' >"$M/spec" ;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
engine_ran() { [ -s "$FAKE_LOG.engine" ] && grep -q -F 'nvidia-smi' "$FAKE_LOG.engine"; }
|
||||
|
||||
# --- check
|
||||
reset have-toolkit current
|
||||
run "$S/ygg-gpu.sh" check
|
||||
check "check with everything in place prints the GPU and exits 0" bash -c "[ $rc = 0 ] && grep -q -F 'gpu: NVIDIA GeForce RTX 4090 24564 MiB' '$T/out'"
|
||||
check "it ran the CUDA image pinned by digest through ygg-engine" grep -q -F 'docker.io/nvidia/cuda@sha256:59436e8ac61921052d8f420be8b8cb8b117f1d7cc643397a289e37aaa0b83ea1' "$FAKE_LOG.engine"
|
||||
check "with the CDI device" grep -q -F -- '--device nvidia.com/gpu=all' "$FAKE_LOG.engine"
|
||||
check "and asked nvidia-smi for the name and memory" grep -q -F -- 'nvidia-smi --query-gpu=name,memory.total --format=csv,noheader' "$FAKE_LOG.engine"
|
||||
check "and it found the default machine with podman machine inspect" grep -q -F 'machine inspect' "$FAKE_LOG.podman"
|
||||
|
||||
reset no-toolkit none
|
||||
run "$S/ygg-gpu.sh" check
|
||||
check "check without the toolkit exits 3 and says missing: toolkit" bash -c "[ $rc = 3 ]"
|
||||
check "and names the command that fixes it" has "ygg-gpu.sh setup"
|
||||
check "and says missing: toolkit" has "missing: toolkit"
|
||||
check "and does not run the container" bash -c "! grep -q nvidia-smi '$FAKE_LOG.engine'"
|
||||
|
||||
reset have-toolkit none
|
||||
run "$S/ygg-gpu.sh" check
|
||||
check "check with the toolkit and no spec exits 3: missing: spec" bash -c "[ $rc = 3 ] && grep -q -F 'missing: spec' '$T/out' && ! grep -q 'stale' '$T/out'"
|
||||
|
||||
reset have-toolkit stale
|
||||
run "$S/ygg-gpu.sh" check
|
||||
check "check with a spec naming a gone driver folder exits 3: missing: stale spec" bash -c "[ $rc = 3 ] && grep -q -F 'missing: stale spec' '$T/out'"
|
||||
check "and names setup" has "ygg-gpu.sh setup"
|
||||
|
||||
reset have-toolkit current
|
||||
FAKE_ENGINE_DOWN=1 run "$S/ygg-gpu.sh" check
|
||||
check "check exits 2 when the engine does not answer" bash -c "[ $rc = 2 ] && grep -q 'engine socket' '$T/out'"
|
||||
|
||||
# --- setup
|
||||
reset no-toolkit none
|
||||
run "$S/ygg-gpu.sh" setup
|
||||
check "setup on an empty machine installs the pinned toolkit, generates the spec and runs check" bash -c "[ $rc = 0 ] && grep -q -F 'gpu: NVIDIA GeForce RTX 4090' '$T/out'"
|
||||
check "the install is pinned to 1.20.1 and from NVIDIA's rpm repo" bash -c "grep -F 'dnf install' '$FAKE_LOG' | grep -q -F '1.20.1' && grep -q -F 'nvidia.github.io/libnvidia-container/stable/rpm/nvidia-container-toolkit.repo' '$FAKE_LOG'"
|
||||
check "the spec is generated once into /etc/cdi/nvidia.yaml" bash -c "[ \"\$(grep -c -F 'cdi generate --output=/etc/cdi/nvidia.yaml' '$FAKE_LOG')\" = 1 ]"
|
||||
|
||||
reset have-toolkit current
|
||||
run "$S/ygg-gpu.sh" setup
|
||||
check "setup with all in place changes nothing: no dnf, no cdi generate" bash -c "[ $rc = 0 ] && ! grep -q -e 'dnf' -e 'cdi generate' '$FAKE_LOG'"
|
||||
check "and it still runs the check" engine_ran
|
||||
|
||||
reset have-toolkit none
|
||||
run "$S/ygg-gpu.sh" setup
|
||||
check "setup with the toolkit and no spec only generates the spec" bash -c "[ $rc = 0 ] && ! grep -q 'dnf' '$FAKE_LOG' && grep -q 'cdi generate' '$FAKE_LOG'"
|
||||
|
||||
reset have-toolkit stale
|
||||
run "$S/ygg-gpu.sh" setup
|
||||
check "setup with a stale spec generates it again, without dnf" bash -c "[ $rc = 0 ] && ! grep -q 'dnf' '$FAKE_LOG' && grep -q 'cdi generate' '$FAKE_LOG' && grep -q 'gpu:' '$T/out'"
|
||||
check "and the new spec names no gone folder" bash -c "! grep -q gone_c '$M/spec'"
|
||||
|
||||
# the engine is checked before any wsl.exe -d call (a wsl.exe -d boots a stopped distro)
|
||||
reset no-toolkit none
|
||||
FAKE_ENGINE_DOWN=1 run "$S/ygg-gpu.sh" setup
|
||||
check "setup with an engine that does not answer exits 2 and made no wsl.exe -d call" bash -c "[ $rc = 2 ] && grep -q 'engine socket' '$T/out' && [ ! -s '$FAKE_LOG' ]"
|
||||
reset have-toolkit current
|
||||
FAKE_ENGINE_DOWN=1 run "$S/ygg-gpu.sh" check
|
||||
check "check with an engine that does not answer made no wsl.exe -d call either" bash -c "[ $rc = 2 ] && [ ! -s '$FAKE_LOG' ]"
|
||||
|
||||
# state: a transport failure is not a missing state
|
||||
reset have-toolkit current
|
||||
FAKE_WSL_FAIL=1 run "$S/ygg-gpu.sh" check
|
||||
check "check: a wsl.exe that fails is exit 2, not missing: toolkit" bash -c "[ $rc = 2 ] && ! grep -q 'missing' '$T/out'"
|
||||
reset have-toolkit current
|
||||
FAKE_WSL_FAIL=1 run "$S/ygg-gpu.sh" setup
|
||||
check "setup: a wsl.exe that fails is exit 2, and installs and generates nothing" bash -c "[ $rc = 2 ] && ! grep -q -e dnf -e 'cdi generate' '$FAKE_LOG'"
|
||||
reset have-toolkit current
|
||||
FAKE_WSL_EMPTY=1 run "$S/ygg-gpu.sh" check
|
||||
check "check: an empty answer from the probe is exit 2" bash -c "[ $rc = 2 ] && ! grep -q 'missing' '$T/out'"
|
||||
reset have-toolkit none
|
||||
printf 'devices:\n path: /opt/other/x.so\n' >"$M/spec"
|
||||
run "$S/ygg-gpu.sh" check
|
||||
check "check: a spec that names no /usr/lib/wsl/drivers/ folder is stale" bash -c "[ $rc = 3 ] && grep -q -F 'missing: stale spec' '$T/out'"
|
||||
reset have-toolkit stale
|
||||
run "$S/ygg-gpu.sh" check --folder "$T/it2"
|
||||
check "the printed fix keeps --folder when one was given" bash -c "grep -q -F -- 'fix: ' '$T/out' && grep -F 'fix: ' '$T/out' | grep -q -F -- '--folder $T/it2'"
|
||||
reset have-toolkit stale
|
||||
run "$S/ygg-gpu.sh" check
|
||||
check "and the fix has no --folder when none was given" bash -c "grep -F 'fix: ' '$T/out' | grep -q -v -F -- '--folder'"
|
||||
check "the probe is one script: the state function interpolates nothing into sh -c" bash -c "! grep -E 'root \"(cat|test -d)' '$S/ygg-gpu.sh'"
|
||||
|
||||
# the real wsl.exe expands $... in a command line: scripts go on stdin (sh -s), never as sh -c "$script"
|
||||
check "root() sends scripts on stdin to sh -s, nothing on the command line" bash -c "! grep -q 'sh -c' '$S/ygg-gpu.sh' && grep -q 'sh -s' '$S/ygg-gpu.sh'"
|
||||
reset have-toolkit current
|
||||
|
||||
run bash -c "printf '%s\n' 'x=1; echo \"[stdin \$x]\"' | '$T/bin/wsl.exe' -d podman-machine-default -u root -- sh -s"
|
||||
check "the fake runs a stdin script unchanged" has "[stdin 1]"
|
||||
# shellcheck disable=SC2016 # the $x must reach the fake unexpanded
|
||||
run "$T/bin/wsl.exe" -d podman-machine-default -u root -- sh -c 'x=1; echo "[cmdline $x]"'
|
||||
check "the fake loses \$x on the command line, as the real wsl.exe does" has "[cmdline ]"
|
||||
|
||||
# --- refusals: nothing changes
|
||||
reset no-toolkit none
|
||||
echo some-other-distro >"$M/distro"
|
||||
run "$S/ygg-gpu.sh" setup
|
||||
check "setup refuses when the machine has no WSL distro" bash -c "[ $rc != 0 ] && grep -q -i 'not a podman machine on wsl2' '$T/out'"
|
||||
check "and changed nothing (no install, no generate)" bash -c "! grep -q -e dnf -e 'cdi generate' '$FAKE_LOG'"
|
||||
reset no-toolkit none
|
||||
run "$S/ygg-gpu.sh" setup --machine other
|
||||
check "setup --machine NAME refuses a machine that has no distro" bash -c "[ $rc != 0 ] && grep -q -i 'not a podman machine on wsl2' '$T/out' && ! grep -q dnf '$FAKE_LOG'"
|
||||
reset no-toolkit none
|
||||
FAKE_NOMACHINE=1 run "$S/ygg-gpu.sh" setup
|
||||
check "setup refuses when podman has no default machine" bash -c "[ $rc != 0 ] && grep -q -i 'not a podman machine on wsl2' '$T/out' && ! grep -q dnf '$FAKE_LOG'"
|
||||
reset have-toolkit current
|
||||
mkdir -p "$T/it"; printf 'sandbox:\n engine: unix://%s/podman-sockets/podman-machine-default/podman-user.sock\n' "$T" >"$T/it/factory.yaml"
|
||||
FAKE_NOMACHINE=1 run "$S/ygg-gpu.sh" check --folder "$T/it"
|
||||
check "without podman machine inspect, the machine is the one the engine socket names" bash -c "[ $rc = 0 ] && grep -q 'gpu:' '$T/out'"
|
||||
reset no-toolkit none
|
||||
mv "$T/bin/wsl.exe" "$T/wsl.exe.off"
|
||||
run "$S/ygg-gpu.sh" setup
|
||||
check "setup refuses when wsl.exe is not there (not WSL2)" bash -c "[ $rc != 0 ] && grep -q -i 'not a podman machine on wsl2' '$T/out'"
|
||||
mv "$T/wsl.exe.off" "$T/bin/wsl.exe"
|
||||
run "$S/ygg-gpu.sh" frobnicate
|
||||
check "an unknown mode prints usage and exits 2" bash -c "[ $rc = 2 ] && grep -q usage '$T/out'"
|
||||
|
||||
# --- the machine is never touched, and nothing runs as a privileged user through sudo
|
||||
reset no-toolkit none
|
||||
run "$S/ygg-gpu.sh" setup
|
||||
check "setup never called sudo" bash -c "[ ! -e '$FAKE_LOG.sudo' ]"
|
||||
check "and never started, stopped or re-created the machine" bash -c "! grep -q -E 'machine (start|stop|init|rm|reset|set)' '$FAKE_LOG.podman'"
|
||||
check "and set no CONTAINER_HOST itself" bash -c "! grep -q CONTAINER_HOST '$S/ygg-gpu.sh'"
|
||||
|
||||
hermetic_done
|
||||
echo; [ "$fail" -eq 0 ] && echo "ygg-gpu.test.sh: all ok" || echo "ygg-gpu.test.sh: FAILURES"
|
||||
exit "$fail"
|
||||
Executable
+401
@@ -0,0 +1,401 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests for ygg-models.sh. scripts/tests/ygg-models.test.sh
|
||||
# Fakes stand in for the tools: ygg (YGG_CMD) answers `models server|preset|image|build`; ygg-engine.sh
|
||||
# (YGG_ENGINE_SH) runs the fake podman; ygg-gpu.sh (YGG_GPU_SH) is a fake; curl is a fake whose server is a few
|
||||
# files. The whole test runs under env -i (hermetic.sh): no real engine, GPU or ygg is ever called.
|
||||
# Each check is a condition string run by eval: $rc and the helpers are read there, so the linter cannot see them used.
|
||||
# shellcheck disable=SC2016,SC2034,SC2329
|
||||
set -uo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source-path=SCRIPTDIR source=hermetic.sh
|
||||
. "$HERE/hermetic.sh"
|
||||
HERMETIC_TOOLS="python3" hermetic_enter "$@"
|
||||
S="$HERE/.."
|
||||
M="$S/ygg-models.sh"
|
||||
trap 'rm -rf -- "$T"' EXIT
|
||||
fail=0
|
||||
check() {
|
||||
local d="$1"; shift
|
||||
if "$@"; then echo "ok $d"; else echo "FAIL $d"; fail=1; [ -z "${VERBOSE:-}" ] || sed 's/^/ | /' "$T/out"; fi
|
||||
}
|
||||
run() {
|
||||
hermetic_path_ok "$PATH" || { echo "FAIL the test's PATH leaves its folder: $PATH"; exit 3; }
|
||||
"$@" >"$T/out" 2>&1; rc=$?
|
||||
}
|
||||
has() { grep -q -F -- "$1" "$T/out"; }
|
||||
logged() { grep -q -F -- "$1" "$T/podman.log" 2>/dev/null; }
|
||||
count() { grep -c -F -- "$1" "$T/podman.log" 2>/dev/null || true; }
|
||||
# arg_pair FLAG VALUE: the run call has FLAG and then VALUE as two whole arguments (one per line in podman.args)
|
||||
arg_pair() { awk -v f="$1" -v v="$2" 'prev == f && $0 == v { ok = 1 } { prev = $0 } END { exit !ok }' "$T/podman.args"; }
|
||||
runline() { grep '^run ' "$T/podman.log" | head -n 1; }
|
||||
|
||||
W="$T/weights dir" # a path with a space: the lines of `ygg models server` are data
|
||||
ST="$T/state"
|
||||
EW="/mnt/engine/weights dir" # where the engine sees the folders (sandbox.mounts)
|
||||
EST="/mnt/engine/state"
|
||||
SD="$T/st" # the fakes' state: container, running, image, network, internal, up
|
||||
export FAKE_T="$T"
|
||||
mkdir -p "$W" "$T/fakes" "$SD"
|
||||
|
||||
cat >"$T/fakes/ygg" <<'FAKE'
|
||||
#!/bin/bash
|
||||
echo "ygg $*" >>"$FAKE_T/ygg.log"
|
||||
[ "$1" = models ] || exit 64
|
||||
case "$2" in
|
||||
server) # models server --config CONFIG FILE
|
||||
[ "$3" = --config ] || { echo "ygg: models server needs --config" >&2; exit 1; }
|
||||
[ -z "$FAKE_YGG_REFUSE" ] || { echo "ygg: $FAKE_YGG_REFUSE" >&2; exit 1; }
|
||||
[ -f "$5" ] || { echo "ygg: $5: no such file" >&2; exit 1; }
|
||||
cat "$FAKE_T/server.out" ;;
|
||||
preset) [ -f "$3" ] || exit 1; printf 'version = 1\n[qwen3.8-27b]\nmodel = /models/model.gguf\n' ;;
|
||||
image) echo "ygg-models:abc123" ;;
|
||||
build) touch "$FAKE_T/st/image" ;;
|
||||
*) exit 64 ;;
|
||||
esac
|
||||
FAKE
|
||||
cat >"$T/engine.sh" <<'FAKE'
|
||||
#!/bin/bash
|
||||
# ygg-engine.sh run [--folder DIR] -- CMD...: runs the fake podman for podman, and the command for the rest
|
||||
echo "engine $*" >>"$FAKE_T/engine.log"
|
||||
[ "$1" = run ] || exit 64
|
||||
shift
|
||||
while [ "$1" != -- ]; do shift; done
|
||||
shift
|
||||
[ -z "$FAKE_ENGINE_DOWN" ] || { echo "ygg-engine: the engine socket is not there" >&2; exit 2; }
|
||||
if [ "$1" = podman ]; then shift; exec "$FAKE_T/fakes/podman" "$@"; fi
|
||||
exec "$@"
|
||||
FAKE
|
||||
cat >"$T/fakes/podman" <<'FAKE'
|
||||
#!/bin/bash
|
||||
st="$FAKE_T/st"
|
||||
echo "$*" >>"$FAKE_T/podman.log"
|
||||
{ printf '%s\n' "$@"; echo ---; } >>"$FAKE_T/podman.args" # one argument per line
|
||||
case "$1 $2" in
|
||||
"container ls") # --filter name=^NAME$ matches the name exactly; FAKE_ID_CLASH lists another container's name
|
||||
[ -e "$st/container" ] && echo ygg-models
|
||||
[ -z "$FAKE_ID_CLASH" ] || echo "$FAKE_ID_CLASH"
|
||||
exit 0 ;;
|
||||
"image exists") [ -z "$FAKE_IMAGE_ERR" ] || exit 125; [ -e "$st/image" ] ;;
|
||||
"network exists") [ -e "$st/network" ] ;;
|
||||
"network inspect") cat "$st/internal" ;;
|
||||
"network create") touch "$st/network"; echo true >"$st/internal" ;;
|
||||
"container inspect") cat "$st/running" ;;
|
||||
"logs --tail") echo "fake log: unknown key 'bogus' in the preset" ;;
|
||||
"rm -f") rm -f "$st/container" "$st/running" "$st/up" ;;
|
||||
"stop "*) echo false >"$st/running"; rm -f "$st/up" ;;
|
||||
"rm "*) rm -f "$st/container" "$st/running" ;;
|
||||
"run -d")
|
||||
[ -z "$FAKE_RUN_CDI" ] || { echo "Error: setting up CDI devices: unresolvable CDI devices nvidia.com/gpu=all" >&2; exit 125; }
|
||||
touch "$st/container"
|
||||
if [ -n "$FAKE_RUN_EXITS" ]; then echo false >"$st/running"; else echo true >"$st/running"; touch "$st/up"; fi
|
||||
echo "0123456789abcdef" ;;
|
||||
*) echo "fake podman: unexpected $*" >&2; exit 65 ;;
|
||||
esac
|
||||
FAKE
|
||||
cat >"$T/fakes/gpu.sh" <<'FAKE'
|
||||
#!/bin/bash
|
||||
echo "gpu $*" >>"$FAKE_T/gpu.log"
|
||||
case "$1" in
|
||||
check)
|
||||
if [ -e "$FAKE_T/gpu.state" ]; then cat "$FAKE_T/gpu.state"; echo "fix: scripts/ygg-gpu.sh setup"; exit 3; fi
|
||||
echo "gpu: NVIDIA GeForce RTX 4090 24564 MiB" ;;
|
||||
setup) rm -f "$FAKE_T/gpu.state"; echo "gpu: NVIDIA GeForce RTX 4090 24564 MiB" ;;
|
||||
esac
|
||||
FAKE
|
||||
cat >"$T/bin/curl" <<'FAKE'
|
||||
#!/bin/bash
|
||||
# curl -sS -m N -o FILE -w '%{http_code}' [-X POST -H H -d BODY] URL
|
||||
out="" method=GET body=""
|
||||
args=("$@")
|
||||
for ((i = 0; i < ${#args[@]}; i++)); do
|
||||
case "${args[$i]}" in -o) out="${args[$((i + 1))]}" ;; -X) method="${args[$((i + 1))]}" ;; -d) body="${args[$((i + 1))]}" ;; esac
|
||||
done
|
||||
url="${args[$((${#args[@]} - 1))]}"
|
||||
echo "$method $url $body" >>"$FAKE_T/curl.log"
|
||||
[ -e "$FAKE_T/st/up" ] || exit 7
|
||||
case "$method $url" in
|
||||
"GET "*/props\?model=*) [ -z "$FAKE_NO_PROPS" ] || { printf 404; exit 0; }; cat "$FAKE_T/props.json" >"$out"; printf 200 ;;
|
||||
"GET "*/models) cat "$FAKE_T/models.json" >"$out"; printf 200 ;;
|
||||
"POST "*/models/load)
|
||||
code="$(cat "$FAKE_T/load.code" 2>/dev/null || echo 200)"
|
||||
cat "$FAKE_T/load.body" >"$out" 2>/dev/null || : >"$out"
|
||||
case "$code" in 200 | 400) cp "$FAKE_T/models.after.json" "$FAKE_T/models.json" ;; esac
|
||||
printf '%s' "$code" ;;
|
||||
*) exit 64 ;;
|
||||
esac
|
||||
FAKE
|
||||
chmod +x "$T/fakes/"* "$T/engine.sh" "$T/bin/curl"
|
||||
export YGG_CMD="$T/fakes/ygg" YGG_ENGINE_SH="$T/engine.sh" YGG_GPU_SH="$T/fakes/gpu.sh"
|
||||
export YGG_MODELS_POLL=0.1 YGG_MODELS_TIMEOUT=2
|
||||
LIST="$T/models.yaml"; : >"$LIST"
|
||||
|
||||
reset() { # a clean engine, and a list that names $W
|
||||
rm -rf "${SD:?}" "${ST:?}" "${T:?}"/*.log "${T:?}"/podman.args "${T:?}/gpu.state" "${T:?}/load.code" "${T:?}/load.body"; mkdir -p "$SD" "$W"
|
||||
rm -f "$W/chat-template.jinja"
|
||||
unset FAKE_RUN_EXITS FAKE_ENGINE_DOWN FAKE_ID_CLASH FAKE_IMAGE_ERR FAKE_YGG_REFUSE FAKE_NO_PROPS FAKE_RUN_CDI
|
||||
printf 'NAME=ygg-models\nNETWORK=ygg-models\nPORT=8089\nHOST_PORT=8088\nWEIGHTS=%s\nSTATE=%s\nENGINE_WEIGHTS=%s\nENGINE_STATE=%s\n' "$W" "$ST" "$EW" "$EST" >"$T/server.out"
|
||||
printf '{"data":[{"id":"qwen3.8-27b","status":{"value":"unloaded","preset":"version = 1\n[qwen]\nmodel = x"}}]}' >"$T/models.json"
|
||||
printf '{"data":[{"id":"qwen3.8-27b","status":{"value":"loaded","preset":"version = 1\n[qwen]\nmodel = x"}}]}' >"$T/models.after.json"
|
||||
}
|
||||
up() { touch "$SD/container" "$SD/up"; echo true >"$SD/running"; }
|
||||
|
||||
# --- start
|
||||
reset; touch "$SD/image"
|
||||
run bash "$M" --models "$LIST" start
|
||||
check "start: exits 0 and says where the server is" eval '[ $rc = 0 ] && has "http://127.0.0.1:8088"'
|
||||
check "start: podman run gets the GPU, the name, the internal network, and detaches" \
|
||||
eval 'runline | grep -q -F -- "--device nvidia.com/gpu=all" && runline | grep -q -F -- "--name ygg-models" && runline | grep -q -F -- "--network ygg-models" && runline | grep -q -F -- " -d "'
|
||||
check "start: both mounts are read-only, use the engine-side paths, and the weights path keeps its space as one argument" \
|
||||
eval 'arg_pair -v "$EW:/models:ro,Z" && arg_pair -v "$EST:/etc/ygg-models:ro,Z" && ! arg_pair -v "$W:/models:ro,Z" && arg_pair -p 127.0.0.1:8088:8080 && arg_pair -e YGG_MODELGATE_PORT=8089'
|
||||
check "start: the only published port is 127.0.0.1:<host_port>:8080" \
|
||||
eval '[ "$(runline | grep -o -- "-p [^ ]*")" = "-p 127.0.0.1:8088:8080" ] && ! runline | grep -q -E -- "--publish|-P "'
|
||||
check "start: the gate learns its port from the environment" eval 'runline | grep -q -F -- "-e YGG_MODELGATE_PORT=8089"'
|
||||
check "start: the preset from ygg is in the state folder" \
|
||||
eval '[ "$(cat "$ST/preset.ini")" = "$(printf "version = 1\n[qwen3.8-27b]\nmodel = /models/model.gguf")" ]'
|
||||
check "start: it reads the list with ygg models server and models preset, from the file given" \
|
||||
eval 'grep -q -F "ygg models server --config $HOME/ygg-it/factory.yaml $LIST" "$T/ygg.log" && grep -q -F "ygg models preset $LIST" "$T/ygg.log"'
|
||||
check "start: every podman call goes through ygg-engine.sh run" \
|
||||
eval '[ "$(grep -c -- "-- podman" "$T/engine.log")" = "$(wc -l <"$T/podman.log")" ]'
|
||||
check "start: it checks the GPU first" eval '[ "$(head -n 1 "$T/gpu.log")" = "gpu check --folder $HOME/ygg-it" ]'
|
||||
check "start: the image existed, so it was not built" eval '! grep -q "models build" "$T/ygg.log"'
|
||||
check "start: the image is looked up by the tag ygg models image prints" eval 'logged "image exists ygg-models:abc123"'
|
||||
check "start: it creates the network, internal, when it is absent" eval 'logged "network create --internal ygg-models"'
|
||||
|
||||
reset
|
||||
run bash "$M" --models "$LIST" start
|
||||
check "start: a missing image is built with ygg models build, through the engine, before the container runs" \
|
||||
eval '[ $rc = 0 ] && grep -q "^engine run.* -- $T/fakes/ygg models build" "$T/engine.log"'
|
||||
reset; touch "$SD/network" "$SD/image"; echo true >"$SD/internal"
|
||||
run bash "$M" --models "$LIST" start
|
||||
check "start: an existing internal network is kept" eval '[ $rc = 0 ] && ! logged "network create"'
|
||||
reset; touch "$SD/network" "$SD/image"; echo false >"$SD/internal"
|
||||
run bash "$M" --models "$LIST" start
|
||||
check "start: a network that is not internal is refused, and nothing runs" \
|
||||
eval '[ $rc != 0 ] && has "not internal" && [ "$(count "run -d")" = 0 ]'
|
||||
|
||||
# the argv of the server container: dropped capabilities, no new privileges
|
||||
reset; touch "$SD/image"
|
||||
run bash "$M" --models "$LIST" start
|
||||
check "start: the container drops all capabilities and gets no new privileges" \
|
||||
eval 'runline | grep -q -F -- "--cap-drop=ALL" && runline | grep -q -F -- "--security-opt=no-new-privileges"'
|
||||
check "start: the preset is written to the host state folder, not the engine path" eval '[ -f "$ST/preset.ini" ] && [ ! -e "$EST" ]'
|
||||
reset; touch "$SD/image"
|
||||
run bash "$M" --models "$LIST" --folder "$T/fdir" start
|
||||
check "start --folder: ygg models server gets --config DIR/factory.yaml" eval 'grep -q -F "ygg models server --config $T/fdir/factory.yaml $LIST" "$T/ygg.log"'
|
||||
reset; touch "$SD/image"; export FAKE_YGG_REFUSE="the folder $W is under no sandbox.mounts entry"
|
||||
run bash "$M" --models "$LIST" start
|
||||
check "start: a ygg that refuses the folders (no sandbox.mounts covers them) passes its error through, and no engine call" \
|
||||
eval '[ $rc != 0 ] && has "sandbox.mounts" && [ ! -e "$T/podman.log" ]'
|
||||
unset FAKE_YGG_REFUSE
|
||||
reset; touch "$SD/image"; sed -i '/^ENGINE_STATE=/d' "$T/server.out"
|
||||
run bash "$M" --models "$LIST" start
|
||||
check "start: a missing ENGINE_STATE line is an error" eval '[ $rc != 0 ] && has ENGINE_STATE'
|
||||
reset; touch "$SD/image"; export FAKE_RUN_CDI=1
|
||||
run bash "$M" --models "$LIST" start
|
||||
check "start: unresolvable CDI devices names ygg-gpu.sh setup" eval '[ $rc != 0 ] && has "ygg-gpu.sh setup" && has "unresolvable CDI"'
|
||||
unset FAKE_RUN_CDI
|
||||
|
||||
# a list that names no weights folder fails before any call
|
||||
reset; rmdir "$W"
|
||||
run bash "$M" --models "$LIST" start
|
||||
check "start: a weights folder that does not exist fails before any podman, engine or GPU call" \
|
||||
eval '[ $rc != 0 ] && has "$W" && [ ! -e "$T/podman.log" ] && [ ! -e "$T/engine.log" ] && [ ! -e "$T/gpu.log" ]'
|
||||
reset
|
||||
run bash "$M" --models "$T/nothere.yaml" start
|
||||
check "start: a list that ygg refuses stops with ygg's message, and no engine call" \
|
||||
eval '[ $rc != 0 ] && has "no such file" && [ ! -e "$T/podman.log" ]'
|
||||
|
||||
# the GPU
|
||||
reset; touch "$SD/image"; echo "missing: stale spec" >"$T/gpu.state"
|
||||
run bash "$M" --models "$LIST" start
|
||||
check "start: a stale CDI spec is set up again, then the server starts" \
|
||||
eval '[ $rc = 0 ] && grep -q "^gpu setup" "$T/gpu.log" && logged "run -d"'
|
||||
reset; touch "$SD/image"; echo "missing: toolkit" >"$T/gpu.state"
|
||||
run bash "$M" --models "$LIST" start
|
||||
check "start: any other missing GPU state stops and names ygg-gpu.sh setup" \
|
||||
eval '[ $rc != 0 ] && has "ygg-gpu.sh setup" && ! grep -q "^gpu setup" "$T/gpu.log" && [ ! -e "$T/podman.log" ]'
|
||||
|
||||
# a container that exits while starting
|
||||
reset; touch "$SD/image"
|
||||
FAKE_RUN_EXITS=1 run bash "$M" --models "$LIST" start
|
||||
check "start: a container that exits while starting prints the end of its log and fails" \
|
||||
eval '[ $rc != 0 ] && has "unknown key" && logged "logs --tail"'
|
||||
|
||||
# already running, and a stopped one
|
||||
reset; touch "$SD/image"
|
||||
run bash "$M" --models "$LIST" start
|
||||
: >"$T/podman.log"
|
||||
run bash "$M" --models "$LIST" start
|
||||
check "start: a server that already runs is left alone, and says to stop first to apply list changes" eval '[ $rc = 0 ] && has "already running" && has "stop first" && [ "$(count "run -d")" = 0 ]'
|
||||
reset; touch "$SD/image" "$SD/container"; echo false >"$SD/running"
|
||||
run bash "$M" --models "$LIST" start
|
||||
check "start: a stopped container of that name is removed, then the server starts" \
|
||||
eval '[ $rc = 0 ] && logged "rm -f ygg-models" && logged "run -d"'
|
||||
|
||||
# the default list
|
||||
reset; touch "$SD/image"; mkdir -p "$HOME/.config/ygg"; : >"$HOME/.config/ygg/models.yaml"
|
||||
run bash "$M" start
|
||||
check "the list defaults to ~/.config/ygg/models.yaml" \
|
||||
eval '[ $rc = 0 ] && grep -q -F "ygg models server --config $HOME/ygg-it/factory.yaml $HOME/.config/ygg/models.yaml" "$T/ygg.log"'
|
||||
reset; touch "$SD/image"; mkdir -p "$T/xdg/ygg"; : >"$T/xdg/ygg/models.yaml"
|
||||
XDG_CONFIG_HOME="$T/xdg" run bash "$M" start
|
||||
check "XDG_CONFIG_HOME moves the default list" \
|
||||
eval '[ $rc = 0 ] && grep -q -F "ygg models server --config $HOME/ygg-it/factory.yaml $T/xdg/ygg/models.yaml" "$T/ygg.log"'
|
||||
|
||||
# the folder: factory.yaml names the engine; ygg models build needs it, and so do the engine and GPU scripts
|
||||
reset
|
||||
run bash "$M" --models "$LIST" --folder "$T/fdir" start
|
||||
check "start --folder DIR: DIR goes to ygg-engine.sh, ygg-gpu.sh and ygg models build --config DIR/factory.yaml" \
|
||||
eval '[ $rc = 0 ] && ! grep -v -F -e "--folder $T/fdir" "$T/engine.log" && grep -q -F -- "--folder $T/fdir" "$T/gpu.log" && grep -q -F "models build --config $T/fdir/factory.yaml" "$T/engine.log"'
|
||||
reset
|
||||
run bash "$M" --models "$LIST" start
|
||||
check "start without --folder: the folder defaults to ~/ygg-it, in the build too" \
|
||||
eval 'grep -q -F "models build --config $HOME/ygg-it/factory.yaml" "$T/engine.log" && grep -q -F -- "--folder $HOME/ygg-it" "$T/engine.log"'
|
||||
run bash "$M" --models
|
||||
check "--models with no file is a usage error" eval '[ $rc = 2 ] && has usage'
|
||||
run bash "$M" --folder
|
||||
check "--folder with no folder is a usage error" eval '[ $rc = 2 ] && has usage'
|
||||
|
||||
# reserved and odd names are refused before any call
|
||||
for n in host none bridge pasta private slirp4netns ns:/proc/1/ns/net container:abc pasta:-T,80 slirp4netns:cidr=10.0.0.0/24; do
|
||||
reset; sed -i "s|^NETWORK=.*|NETWORK=$n|" "$T/server.out"
|
||||
run bash "$M" --models "$LIST" start
|
||||
check "start: the network '$n' is refused before any engine call" eval '[ $rc != 0 ] && has "$n" && [ ! -e "$T/podman.log" ] && [ ! -e "$T/engine.log" ] && [ ! -e "$T/gpu.log" ]'
|
||||
done
|
||||
reset; sed -i "s|^NAME=.*|NAME=-rf|" "$T/server.out"
|
||||
run bash "$M" --models "$LIST" start
|
||||
check "start: a name that starts with a dash is refused before any engine call" eval '[ $rc != 0 ] && [ ! -e "$T/engine.log" ]'
|
||||
|
||||
# exact names
|
||||
reset; touch "$SD/image"; export FAKE_ID_CLASH=other
|
||||
run bash "$M" --models "$LIST" start
|
||||
check "start: the name is matched exactly: another container in the listing is not ours" \
|
||||
eval '[ $rc = 0 ] && logged "run -d" && ! logged "rm -f"'
|
||||
unset FAKE_ID_CLASH
|
||||
reset; export FAKE_ID_CLASH=cafe0123
|
||||
run bash "$M" --models "$LIST" status
|
||||
check "status: a listing with only another name means absent (exit 3)" eval '[ $rc = 3 ]'
|
||||
unset FAKE_ID_CLASH
|
||||
check "container lookups are by exact name filter and container inspect (not a bare inspect)" \
|
||||
eval 'logged "container ls -a --filter name=^ygg-models\$ --format {{.Names}}" && ! grep -q "^inspect" "$T/podman.log"'
|
||||
reset; touch "$SD/image"; export FAKE_IMAGE_ERR=1
|
||||
run bash "$M" --models "$LIST" start
|
||||
check "start: an engine error from image exists is an error, not a build" \
|
||||
eval '[ $rc = 2 ] && ! grep -q "models build" "$T/ygg.log" 2>/dev/null && [ "$(count "run -d")" = 0 ]'
|
||||
unset FAKE_IMAGE_ERR
|
||||
|
||||
# --- status
|
||||
reset
|
||||
run bash "$M" --models "$LIST" status
|
||||
check "status: exit 3 when the container is absent" eval '[ $rc = 3 ] && has "not running"'
|
||||
reset; up
|
||||
run bash "$M" --models "$LIST" status
|
||||
check "status: running, nothing loaded (the body holds raw newlines in strings)" \
|
||||
eval '[ $rc = 0 ] && [ "$(cat "$T/out")" = "running on http://127.0.0.1:8088, loaded: none" ]'
|
||||
cp "$T/models.after.json" "$T/models.json"
|
||||
run bash "$M" --models "$LIST" status
|
||||
check "status: names the loaded model" \
|
||||
eval '[ $rc = 0 ] && [ "$(cat "$T/out")" = "running on http://127.0.0.1:8088, loaded: qwen3.8-27b" ]'
|
||||
rm -f "$SD/up"
|
||||
run bash "$M" --models "$LIST" status
|
||||
check "status: a container that is up but does not answer yet says so, and exits 0" \
|
||||
eval '[ $rc = 0 ] && has "running on http://127.0.0.1:8088, loaded: unknown"'
|
||||
reset; touch "$SD/container"; echo false >"$SD/running"
|
||||
run bash "$M" --models "$LIST" status
|
||||
check "status: a stopped container is not running (exit 3)" eval '[ $rc = 3 ]'
|
||||
reset; FAKE_ENGINE_DOWN=1 run bash "$M" --models "$LIST" status
|
||||
check "status: an engine that does not answer is exit 2, not 3" eval '[ $rc = 2 ] && has "engine socket"'
|
||||
|
||||
# --- load
|
||||
reset; up
|
||||
run bash "$M" --models "$LIST" load qwen3.8-27b
|
||||
check "load: posts the model and waits until it is loaded" \
|
||||
eval '[ $rc = 0 ] && grep -q -F "POST http://127.0.0.1:8088/models/load {\"model\":\"qwen3.8-27b\"}" "$T/curl.log" && has "loaded"'
|
||||
cp "$T/models.after.json" "$T/models.json"; : >"$T/curl.log"
|
||||
run bash "$M" --models "$LIST" load qwen3.8-27b
|
||||
check "load: a model that is loaded already posts nothing" eval '[ $rc = 0 ] && ! grep -q POST "$T/curl.log"'
|
||||
reset; up
|
||||
echo 400 >"$T/load.code"; echo '{"error":{"message":"model is already running"}}' >"$T/load.body"
|
||||
run bash "$M" --models "$LIST" load qwen3.8-27b
|
||||
check "load: 400 'model is already running' is success" eval '[ $rc = 0 ]'
|
||||
reset; up
|
||||
echo 500 >"$T/load.code"; echo 'boom' >"$T/load.body"
|
||||
run bash "$M" --models "$LIST" load qwen3.8-27b
|
||||
check "load: another answer is an error with the status and the body" eval '[ $rc != 0 ] && has "500" && has boom'
|
||||
run bash "$M" --models "$LIST" load nothere
|
||||
check "load: a model the server does not list is an error" eval '[ $rc != 0 ] && has "does not list"'
|
||||
run bash "$M" --models "$LIST" load
|
||||
check "load: needs a name" eval '[ $rc = 2 ] && has usage'
|
||||
reset; run bash "$M" --models "$LIST" load qwen3.8-27b
|
||||
check "load: a server that is not running says to start it" eval '[ $rc != 0 ] && has "ygg-models.sh start"'
|
||||
reset; up
|
||||
printf '{"data":[{"id":"qwen3.8-27b","status":{"value":"unloaded","failed":true,"exit_code":1}}]}' >"$T/models.after.json"
|
||||
run bash "$M" --models "$LIST" load qwen3.8-27b
|
||||
check "load: a load that failed says the exit code" eval '[ $rc != 0 ] && has "exit_code 1"'
|
||||
|
||||
# a failed load can be asked again: the router keeps failed: true until a new attempt
|
||||
reset; up
|
||||
printf '{"data":[{"id":"qwen3.8-27b","status":{"value":"unloaded","failed":true,"exit_code":1}}]}' >"$T/models.json"
|
||||
run bash "$M" --models "$LIST" load qwen3.8-27b
|
||||
check "load: a model whose last load failed is posted again, and the retry succeeds" \
|
||||
eval '[ $rc = 0 ] && grep -q "POST .*/models/load" "$T/curl.log" && has "loaded qwen3.8-27b"'
|
||||
|
||||
# --- template
|
||||
TPL_OLD="{{- raise_exception('System message must be at the beginning.') }}"
|
||||
mkprops() { python3 -I -c 'import json,sys; print(json.dumps({"chat_template": sys.argv[1]}))' "$1" >"$T/props.json"; }
|
||||
reset; up; cp "$T/models.after.json" "$T/models.json"
|
||||
mkprops "A
|
||||
{%- if x %}
|
||||
$TPL_OLD
|
||||
{%- endif %}
|
||||
B"
|
||||
run bash "$M" --models "$LIST" template qwen3.8-27b
|
||||
check "template: asks the server for /props?model=NAME" eval '[ $rc = 0 ] && grep -q -F "GET http://127.0.0.1:8088/props?model=qwen3.8-27b" "$T/curl.log"'
|
||||
check "template: saves the fixed template in the host weights folder, with the system turn in place of the raise" \
|
||||
eval '[ -f "$W/chat-template.jinja" ] && ! grep -q "System message must be" "$W/chat-template.jinja" && grep -q -F "<|im_start|>system" "$W/chat-template.jinja" && head -n 1 "$W/chat-template.jinja" | grep -qx A'
|
||||
check "template: no temporary file is left, and it tells to uncomment chat-template-file, then stop and start" \
|
||||
eval '! ls "$W" | grep -q tmp && has chat-template-file && has "ygg-models.sh stop" && has "ygg-models.sh start"'
|
||||
reset; up; cp "$T/models.after.json" "$T/models.json"; mkprops "no such line here"
|
||||
run bash "$M" --models "$LIST" template qwen3.8-27b
|
||||
check "template: a template without the line is refused, and nothing is saved" eval '[ $rc != 0 ] && has "did not find" && [ ! -e "$W/chat-template.jinja" ]'
|
||||
reset; up; cp "$T/models.after.json" "$T/models.json"; mkprops "A {{- '<|im_start|>system\\n' + content + '<|im_end|>\\n' }} B"
|
||||
run bash "$M" --models "$LIST" template qwen3.8-27b
|
||||
check "template: a template that has the fix already says so, exits 0 and saves nothing" eval '[ $rc = 0 ] && has "already in place" && [ ! -e "$W/chat-template.jinja" ]'
|
||||
reset; up; cp "$T/models.after.json" "$T/models.json"; mkprops "$TPL_OLD $TPL_OLD"
|
||||
run bash "$M" --models "$LIST" template qwen3.8-27b
|
||||
check "template: a line found twice is refused" eval '[ $rc != 0 ] && has "did not find" && [ ! -e "$W/chat-template.jinja" ]'
|
||||
reset; up; mkprops "$TPL_OLD"
|
||||
run bash "$M" --models "$LIST" template qwen3.8-27b
|
||||
check "template: a model that is not loaded is refused with the load command" eval '[ $rc != 0 ] && has "ygg-models.sh load qwen3.8-27b" && [ ! -e "$W/chat-template.jinja" ]'
|
||||
reset
|
||||
run bash "$M" --models "$LIST" template qwen3.8-27b
|
||||
check "template: a server that is not running says to start it" eval '[ $rc != 0 ] && has "ygg-models.sh start"'
|
||||
reset; up; cp "$T/models.after.json" "$T/models.json"; mkprops "$TPL_OLD"
|
||||
run bash "$M" --models "$LIST" template 'a b&c'
|
||||
check "template: a name that is not plain is refused" eval '[ $rc != 0 ] && [ ! -e "$T/curl.log" ]'
|
||||
run bash "$M" --models "$LIST" template
|
||||
check "template: needs a name" eval '[ $rc = 2 ] && has usage'
|
||||
|
||||
# --- stop
|
||||
reset; touch "$SD/network" "$SD/image"; up
|
||||
run bash "$M" --models "$LIST" stop
|
||||
check "stop: stops and removes the container, and touches neither the network nor the image" \
|
||||
eval '[ $rc = 0 ] && logged "stop" && logged "rm" && [ ! -e "$SD/container" ] && ! logged "network rm" && ! logged "rmi" && ! logged "image rm" && ! logged "prune" && [ -e "$SD/network" ] && [ -e "$SD/image" ]'
|
||||
reset
|
||||
run bash "$M" --models "$LIST" stop
|
||||
check "stop: with no container it says so and exits 0" eval '[ $rc = 0 ] && has "not running"'
|
||||
|
||||
# --- usage and hygiene
|
||||
run bash "$M"
|
||||
check "no command is a usage error" eval '[ $rc = 2 ] && has usage'
|
||||
run bash "$M" frobnicate
|
||||
check "an unknown command is a usage error" eval '[ $rc = 2 ] && has usage'
|
||||
check "the script never sets CONTAINER_HOST, and never evals or sources the lines of ygg" \
|
||||
eval '! grep -v "^[[:space:]]*#" "$M" | grep -E "CONTAINER_HOST|\beval\b|^[[:space:]]*(source|\.)[[:space:]]"'
|
||||
EX="$S/testdata/models/models.example.yaml"
|
||||
check "the example list exists, and names no local path or operator" \
|
||||
eval '[ -f "$EX" ] && ! grep -E "/home/|/Users/|/mnt/|/root/" "$EX" && ! grep -w -F -e "$(id -un)" -e "$(basename "$HOME")" "$EX" && grep -q "weights: /abs/" "$EX" && grep -q "state: /abs/" "$EX"'
|
||||
check "the example list holds the MTP, memory-safe and template keys, and every args key is a valid preset key name" \
|
||||
eval 'grep -q "cache-ram: \"2048\"" "$EX" && grep -q "ctx-checkpoints: \"4\"" "$EX" && grep -q "spec-type: draft-mtp" "$EX" && grep -q "model-draft: /models/mtp.gguf" "$EX" && grep -q "n-gpu-layers-draft: all" "$EX" && grep -q "cache-type-k-draft: q8_0" "$EX" && grep -q "cache-type-v-draft: q8_0" "$EX" && grep -q "# chat-template-file: /models/chat-template.jinja" "$EX" && ! grep -E "^ [A-Za-z0-9_-]+:" "$EX" | grep -v -E "^ [a-z][a-z0-9-]*:"'
|
||||
hermetic_done
|
||||
exit $fail
|
||||
@@ -255,6 +255,53 @@ done
|
||||
runq proof --name ok --reviewer-reasoning extreme; check "a reasoning outside low, medium, high and off is refused" refuses "--reviewer-reasoning"
|
||||
runq proof --name ok --reviewer-reasoning 'low }'; check "a reasoning with YAML characters is refused" refuses "--reviewer-reasoning"
|
||||
|
||||
# --- local-models LM-T13: opencode:local/<name> with --models FILE
|
||||
printf 'models: []\n' >"$T/models.yaml"; : >"$T/env-none"
|
||||
LR=(--planner claude-code:sonnet --implementer claude-code:sonnet --reviewer opencode:local/qwen3.8-27b)
|
||||
run proof --name local --dry-run --env "$T/env-claude" --models "$T/models.yaml" "${LR[@]}"
|
||||
P="$(made)"
|
||||
check "a local reviewer with --models: exit 0" test "$rc" -eq 0
|
||||
check "the config has the local reviewer, with no reasoning" grep -q -x ' reviewer: { agent: opencode, model: local/qwen3.8-27b }' "$P/factory.yaml"
|
||||
check "the config has models.file" grep -q -x " file: $T/models.yaml" "$P/factory.yaml"
|
||||
check "the models section is the file's last" bash -c 'tail -n 2 "$1" | head -n 1 | grep -q -x "models:"' _ "$P/factory.yaml"
|
||||
check "no usd line when the other roles are claude-code" bash -c '! grep -q "usd:" "$1"' _ "$P/factory.yaml"
|
||||
check "no reasoning in the roles" bash -c '! grep -q "^ [a-z]*: {.*reasoning" "$1"' _ "$P/factory.yaml"
|
||||
check "it does not ask for OPENROUTER_API_KEY" hasnt OPENROUTER_API_KEY
|
||||
check "it says the reasoning is left out" has "the reviewer is a local model, so its reasoning is left out"
|
||||
check "the plan says no reasoning" has "reviewer opencode:local/qwen3.8-27b (no reasoning: a local model sets none)"
|
||||
check "ygg doctor ran on it" grep -q -- "^doctor --project .*/project --config $P/factory.yaml\$" "$T/ygg.calls"
|
||||
check "proof.meta keeps the local reviewer" grep -q '^reviewer opencode:local/qwen3.8-27b$' "$P/proof.meta"
|
||||
runq proof --name local --dry-run --env "$T/env-claude" "${LR[@]}"
|
||||
check "a local role without --models is refused" refuses "--models FILE"
|
||||
runq proof --name local --dry-run --env "$T/env-claude" --models "$T/nope.yaml" "${LR[@]}"
|
||||
check "a models file that is missing is refused" refuses "no such file"
|
||||
runq proof --name local --dry-run --env "$T/env-claude" --models "$T/models.yaml" "${AC[@]}"
|
||||
check "--models with no local role is refused" refuses "no role is on one"
|
||||
runq proof --name local --dry-run --env "$T/env-claude" --models "$T/models.yaml" --planner claude-code:sonnet --implementer claude-code:sonnet --reviewer opencode:local/Bad
|
||||
check "a bad local name is refused" refuses "local/<name>"
|
||||
rm -rf "$D"/proof-*
|
||||
(cd "$T" && run proof --name localrel --dry-run --env "$T/env-claude" --models models.yaml "${LR[@]}")
|
||||
check "a relative --models path is made absolute" grep -q -x " file: $T/models.yaml" "$(made)/factory.yaml"
|
||||
rm -rf "$D"/proof-*
|
||||
run proof --name localmix --dry-run --env "$T/env-claude" --models "$T/models.yaml" --reviewer-reasoning high --planner claude-code:sonnet --implementer claude-code:sonnet --reviewer opencode:local/qwen3.8-27b
|
||||
check "a local reviewer ignores --reviewer-reasoning" bash -c '! grep -q "^ [a-z]*: {.*reasoning" "$1"' _ "$(made)/factory.yaml"
|
||||
rm -rf "$D"/proof-*
|
||||
run proof --name localpaid --dry-run --models "$T/models.yaml" --planner opencode:local/a --implementer opencode:local/a
|
||||
check "a role on OpenRouter beside local ones: exit 0" test "$rc" -eq 0
|
||||
check "it keeps the usd lines" grep -q 'per_run: { tokens: 12000000, turns: 1500, usd: 5 }' "$(made)/factory.yaml"
|
||||
check "and the reviewer's reasoning" grep -q 'reviewer: { agent: opencode, model: openrouter/qwen/qwen3.8-27b, reasoning: medium }' "$(made)/factory.yaml"
|
||||
runq proof --name localpaid --dry-run --env "$T/env-claude" --models "$T/models.yaml" --planner opencode:local/a --implementer opencode:local/a
|
||||
check "and asks for OPENROUTER_API_KEY" refuses "OPENROUTER_API_KEY"
|
||||
run proof --name alllocal --dry-run --env "$T/env-none" --models "$T/models.yaml" --planner opencode:local/a --implementer opencode:local/a --reviewer opencode:local/a --retro-agent opencode --retro-model local/a
|
||||
check "all-local roles need no credentials: exit 0" test "$rc" -eq 0
|
||||
check "all-local asks for neither name" bash -c '! grep -q -e CLAUDE_CODE_OAUTH_TOKEN -e OPENROUTER_API_KEY "$1"' _ "$T/out"
|
||||
|
||||
runq proof --name twolocal --dry-run --env "$T/env-none" --models "$T/models.yaml" --planner opencode:local/a --implementer opencode:local/b --reviewer opencode:local/a
|
||||
check "roles on two different local models are refused" refuses "two different local models"
|
||||
check "and nothing was made" bash -c '! ls "$1"/proof-twolocal-* >/dev/null 2>&1' _ "$D"
|
||||
runq proof --name twolocal --dry-run --env "$T/env-none" --models "$T/models.yaml" --planner opencode:local/a --implementer opencode:local/a --reviewer opencode:local/a --retro-agent opencode --retro-model local/b
|
||||
check "the retrospective role counts too" refuses "two different local models"
|
||||
|
||||
# --- the dry run
|
||||
rm -rf "$D"/proof-* "$T/ygg.calls"
|
||||
run proof --name dry --dry-run
|
||||
|
||||
@@ -318,6 +318,51 @@ for bad in opencode qwen opencode: :sonnet scripted:x gemini:x opencode:qwen/qwe
|
||||
done
|
||||
check "a refusal says what is allowed" has "--implementer is AGENT:MODEL"
|
||||
|
||||
# --- local-models LM-T13: opencode:local/<name> with --models FILE
|
||||
printf 'models: []\n' >"$T/models.yaml"
|
||||
run "$S/ygg-smoke-real.sh" "${rargs[@]}" --implementer opencode:local/qwen3.8-27b --models "$T/models.yaml"
|
||||
W="$(work)"
|
||||
check "a local implementer with --models: exit 0" test "$rc" -eq 0
|
||||
check "the config has the local implementer" grep -q -x ' implementer: { agent: opencode, model: local/qwen3.8-27b } # set by ygg-smoke-real.sh --implementer' "$W/factory.yaml"
|
||||
check "the config has models.file" grep -q -x " file: $T/models.yaml" "$W/factory.yaml"
|
||||
check "the models section is the file's last" bash -c 'tail -n 2 "$1" | head -n 1 | grep -q -x "models:"' _ "$W/factory.yaml"
|
||||
check "it prints the implementer" has "smoke-real: the implementer: { agent: opencode, model: local/qwen3.8-27b }"
|
||||
before="$(find "$D" -maxdepth 1 -name 'smoke-real-*' | wc -l)"
|
||||
run "$S/ygg-smoke-real.sh" "${rargs[@]}" --implementer opencode:local/qwen3.8-27b
|
||||
check "a local role without --models is refused" test "$rc" -eq 2
|
||||
check "and says --models FILE is needed" has "--models FILE"
|
||||
check "and made nothing" test "$(find "$D" -maxdepth 1 -name 'smoke-real-*' | wc -l)" -eq "$before"
|
||||
run "$S/ygg-smoke-real.sh" "${rargs[@]}" --models "$T/models.yaml"
|
||||
check "--models with no local role is refused" test "$rc" -eq 2
|
||||
run "$S/ygg-smoke-real.sh" "${rargs[@]}" --implementer opencode:local/qwen3.8-27b --models "$T/nope.yaml"
|
||||
check "a models file that is missing is refused" test "$rc" -eq 2
|
||||
run "$S/ygg-smoke-real.sh" "${rargs[@]}" --implementer opencode:local/Bad --models "$T/models.yaml"
|
||||
check "a bad local name is refused" test "$rc" -eq 2
|
||||
# A local implementer beside an OpenRouter reviewer still needs the OpenRouter key (the example's reviewer is on it).
|
||||
printf 'CLAUDE_CODE_OAUTH_TOKEN=made-up-a\n' >"$T/env-claude"
|
||||
run "$S/ygg-smoke-real.sh" "${rargs[@]}" --env "$T/env-claude" --implementer opencode:local/qwen3.8-27b --models "$T/models.yaml"
|
||||
check "the OpenRouter reviewer still needs OPENROUTER_API_KEY" has "OPENROUTER_API_KEY"
|
||||
# An example with no OpenRouter role: no OPENROUTER_API_KEY, and no usd cap.
|
||||
O2="$T/odin2"; cp -r "$O" "$O2"
|
||||
printf 'roles:\n implementer: { agent: claude-code, model: sonnet }\n reviewer: { agent: claude-code, model: opus }\nbudget:\n per_run: { tokens: 400000, turns: 100, usd: 3 }\n per_day: { tokens: 1000000, turns: 200, usd: 6 }\nsandbox:\n engine: @ENGINE@\n mounts:\n - { host: @FOLDER@, engine: @BIND@ }\ngrader: { tasks_dir: @TASKS@, suite_dir: @SUITES@ }\n' \
|
||||
>"$O2/testdata/smoke-grade/factory.real.example.yaml"
|
||||
run "$S/ygg-smoke-real.sh" --folder "$D" --tasks "$M" --odin "$O2" --env "$T/env-claude" --dry-run
|
||||
W="$(work)"
|
||||
check "no OpenRouter role: no OPENROUTER_API_KEY asked, exit 0" test "$rc" -eq 0
|
||||
check "and no usd cap in the config" bash -c '! grep -q "usd:" "$1"' _ "$W/factory.yaml"
|
||||
check "and the other caps stay" grep -q -x ' per_run: { tokens: 400000, turns: 100 }' "$W/factory.yaml"
|
||||
|
||||
# The example's reviewer on another local model than the implementer: two local models are refused (spec 5.2.1).
|
||||
O3="$T/odin3"; cp -r "$O" "$O3"
|
||||
printf 'roles:\n implementer: { agent: claude-code, model: sonnet }\n reviewer: { agent: opencode, model: local/other }\nbudget:\n per_run: { tokens: 400000, turns: 100 }\n per_day: { tokens: 1000000, turns: 200 }\nsandbox:\n engine: @ENGINE@\n mounts:\n - { host: @FOLDER@, engine: @BIND@ }\ngrader: { tasks_dir: @TASKS@, suite_dir: @SUITES@ }\n' \
|
||||
>"$O3/testdata/smoke-grade/factory.real.example.yaml"
|
||||
before="$(find "$D" -maxdepth 1 -name 'smoke-real-*' | wc -l)"
|
||||
run "$S/ygg-smoke-real.sh" --folder "$D" --tasks "$M" --odin "$O3" --env "$T/env-claude" --models "$T/models.yaml" --implementer opencode:local/qwen3.8-27b --dry-run
|
||||
check "roles on two different local models are refused" test "$rc" -eq 2
|
||||
check "and it says so, and made nothing" bash -c 'grep -q "two different local models" "$1" && [ "$(find "$2" -maxdepth 1 -name "smoke-real-*" | wc -l)" -eq "$3" ]' _ "$T/out" "$D" "$before"
|
||||
run "$S/ygg-smoke-real.sh" --folder "$D" --tasks "$M" --odin "$O3" --env "$T/env-claude" --models "$T/models.yaml" --implementer opencode:local/other --dry-run
|
||||
check "the same local model on two roles is accepted" test "$rc" -eq 0
|
||||
|
||||
# --- --brief-note: the brief is the note, a blank line, then the task's instruction (B-109). A changed brief is
|
||||
# another task version, so the run builds and is graded against a derived copy of the task in the run's folder.
|
||||
mkdir -p "$T/suites0/smoke-notes/v1"; cp -r "$M/smoke-notes/sample-suite/tests" "$T/suites0/smoke-notes/v1/tests"
|
||||
|
||||
Executable
+127
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env bash
|
||||
# The GPU of the factory's engine, set up once and checked again after a driver update or on a new machine, so no
|
||||
# step is done by hand twice. The engine resolves a CDI device, so the NVIDIA container toolkit and the CDI spec
|
||||
# (/etc/cdi/nvidia.yaml) must be where the engine runs: on a rootless Podman machine under WSL2, in the machine's
|
||||
# own WSL distro (Fedora, PID 1 is WSL's init, no systemd, so the toolkit's refresh units never run).
|
||||
# ygg-gpu.sh setup [--machine NAME] [--folder DIR] install the toolkit (pinned) where it is missing, generate
|
||||
# the CDI spec where it is missing or stale, then run check
|
||||
# ygg-gpu.sh check [--machine NAME] [--folder DIR] run a CUDA container through ygg-engine.sh and print
|
||||
# `gpu: <name> <memory>`
|
||||
# NAME defaults to the machine `podman machine inspect` names as the default one (else the one the engine socket
|
||||
# of DIR/factory.yaml names, .../podman-sockets/NAME/...: a Linux podman cannot list the Windows side's machines); its distro is podman-NAME (NAME
|
||||
# itself when it starts with podman-). DIR is passed to ygg-engine.sh (its default is $HOME/ygg-it).
|
||||
# Exit codes: 0 ready; 1 setup or the container failed, or the engine is not a Podman machine on WSL2 (setup
|
||||
# changes nothing then); 2 the engine does not answer, or bad usage; 3 not ready: `missing: toolkit`, `missing: spec`
|
||||
# or `missing: stale spec`, with the command that fixes it (a stale spec is one that names no folder, or a
|
||||
# /usr/lib/wsl/drivers/<folder> that no longer exists).
|
||||
# The engine is checked first (before any wsl.exe -d, which boots a stopped distro), by setup too. The state is read by
|
||||
# one constant probe script in the distro; a wsl.exe that fails or says nothing is exit 2, not a missing state.
|
||||
# Root commands in the distro run as `wsl.exe -d <distro> -u root -- sh -s` (the script on stdin): no sudo. It never starts, stops,
|
||||
# restarts or re-creates the engine or the machine, and sets no engine variable (ygg-engine.sh does).
|
||||
# Pinned: the toolkit YGG_GPU_TOOLKIT (1.20.1) and the CUDA image YGG_GPU_IMAGE (by digest).
|
||||
# Tests: YGG_ENGINE_SH replaces the ygg-engine.sh that sits next to this script.
|
||||
set -euo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
engine_sh="${YGG_ENGINE_SH:-$HERE/ygg-engine.sh}"
|
||||
ver="${YGG_GPU_TOOLKIT:-1.20.1}"
|
||||
image="${YGG_GPU_IMAGE:-docker.io/nvidia/cuda@sha256:59436e8ac61921052d8f420be8b8cb8b117f1d7cc643397a289e37aaa0b83ea1}"
|
||||
spec=/etc/cdi/nvidia.yaml
|
||||
repo_url=https://nvidia.github.io/libnvidia-container/stable/rpm/nvidia-container-toolkit.repo
|
||||
|
||||
usage() { echo "usage: ygg-gpu.sh setup|check [--machine NAME] [--folder DIR]" >&2; exit 2; }
|
||||
[ $# -ge 1 ] || usage
|
||||
mode="$1"; shift
|
||||
case "$mode" in setup | check) ;; *) usage ;; esac
|
||||
machine="" folder=() dir="$HOME/ygg-it"
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--machine) machine="${2:?--machine needs a name}"; shift 2 ;;
|
||||
--folder) dir="${2:?--folder needs a folder}"; folder=(--folder "$dir"); shift 2 ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
refuse() { echo "ygg-gpu: this is not a Podman machine on WSL2 ($1); nothing was changed" >&2; exit 1; }
|
||||
if [ -z "$machine" ]; then
|
||||
machine="$(podman machine inspect --format '{{.Name}}' 2>/dev/null | head -n 1)" || true
|
||||
if [ -z "$machine" ]; then # a Linux podman cannot list the machines of the Windows side: the engine socket names it
|
||||
machine="$(sed -n 's|^[[:space:]]*engine:[[:space:]]*unix://.*/podman-sockets/\([^/]*\)/.*|\1|p' "$dir/factory.yaml" 2>/dev/null | head -n 1)" || true
|
||||
fi
|
||||
[ -n "$machine" ] || refuse "podman names no default machine"
|
||||
fi
|
||||
command -v wsl.exe >/dev/null 2>&1 || refuse "wsl.exe is not there"
|
||||
case "$machine" in podman-*) distro="$machine" ;; *) distro="podman-$machine" ;; esac
|
||||
distros="$(wsl.exe -l -q 2>/dev/null | tr -d '\0\r')" || true # wsl.exe prints UTF-16
|
||||
printf '%s\n' "$distros" | grep -q -x -F -- "$distro" || refuse "WSL has no distro $distro for the machine $machine"
|
||||
|
||||
# The script goes on stdin: wsl.exe hands a command line to the distro's default shell, which expands $... first.
|
||||
root() { printf '%s\n' "$1" | wsl.exe -d "$distro" -u root -- sh -s; }
|
||||
|
||||
# The engine must answer before any wsl.exe -d call: a wsl.exe -d boots a stopped distro, and a machine that is
|
||||
# stopped is the engine's business, not this script's.
|
||||
engine_up() {
|
||||
local err rc=0
|
||||
err="$(mktemp)"
|
||||
"$engine_sh" check "${folder[@]}" >/dev/null 2>"$err" || rc=$?
|
||||
if [ "$rc" -ne 0 ]; then cat "$err" >&2; rm -f -- "$err"; echo "ygg-gpu: the engine does not answer" >&2; exit 2; fi
|
||||
rm -f -- "$err"
|
||||
}
|
||||
engine_up
|
||||
|
||||
# state: set st to ok, toolkit, spec or "stale spec", from one probe script that runs in the distro. The script is a
|
||||
# constant: nothing is interpolated into it. A wsl.exe that fails, or says nothing, is a transport problem (exit 2),
|
||||
# never a missing state. A spec that names no /usr/lib/wsl/drivers/ folder cannot be current: it is stale.
|
||||
# shellcheck disable=SC2016 # the probe runs in the distro: its $ must stay literal
|
||||
probe='command -v nvidia-ctk >/dev/null 2>&1 || { echo toolkit; exit 0; }
|
||||
[ -s /etc/cdi/nvidia.yaml ] || { echo spec; exit 0; }
|
||||
dirs="$(grep -o "/usr/lib/wsl/drivers/[^/\"[:space:]]*" /etc/cdi/nvidia.yaml | sort -u)"
|
||||
[ -n "$dirs" ] || { echo "stale spec"; exit 0; }
|
||||
set -f
|
||||
for d in $dirs; do
|
||||
[ -d "$d" ] || { echo "stale spec"; exit 0; }
|
||||
done
|
||||
echo ok'
|
||||
st=""
|
||||
state() {
|
||||
local out rc=0
|
||||
out="$(root "$probe" 2>/dev/null)" || rc=$?
|
||||
out="$(printf '%s\n' "$out" | tr -d '\r' | tail -n 1)"
|
||||
case "$rc:$out" in
|
||||
0:ok | 0:toolkit | 0:spec | "0:stale spec") st="$out" ;;
|
||||
*) echo "ygg-gpu: could not read the state of the GPU setup in $distro (wsl.exe exit $rc, answer '$out')" >&2; exit 2 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
do_check() {
|
||||
local out
|
||||
state
|
||||
if [ "$st" != ok ]; then
|
||||
echo "missing: $st"
|
||||
printf 'fix: %s setup --machine %q' "$0" "$machine"
|
||||
[ ${#folder[@]} -eq 0 ] || printf ' --folder %q' "$dir"
|
||||
echo
|
||||
exit 3
|
||||
fi
|
||||
out="$("$engine_sh" run "${folder[@]}" -- podman run --rm --device nvidia.com/gpu=all "$image" \
|
||||
nvidia-smi --query-gpu=name,memory.total --format=csv,noheader 2>&1)" || {
|
||||
echo "ygg-gpu: the CUDA container failed (after a Windows driver update, run scripts/ygg-gpu.sh setup):" >&2; printf '%s\n' "$out" >&2; exit 1
|
||||
}
|
||||
echo "gpu: $(printf '%s\n' "$out" | tail -n 1 | sed 's/, / /')"
|
||||
}
|
||||
|
||||
if [ "$mode" = setup ]; then
|
||||
state
|
||||
if [ "$st" = toolkit ]; then
|
||||
echo "ygg-gpu: installing the NVIDIA container toolkit $ver in $distro"
|
||||
v="$ver-1"
|
||||
root "curl -fsSL $repo_url -o /etc/yum.repos.d/nvidia-container-toolkit.repo && dnf install -y nvidia-container-toolkit-$v nvidia-container-toolkit-base-$v libnvidia-container-tools-$v libnvidia-container1-$v </dev/null" ||
|
||||
{ echo "ygg-gpu: the toolkit install failed" >&2; exit 1; }
|
||||
st=spec
|
||||
fi
|
||||
if [ "$st" = spec ] || [ "$st" = "stale spec" ]; then
|
||||
echo "ygg-gpu: generating the CDI spec ($st) in $distro"
|
||||
root "mkdir -p /etc/cdi && nvidia-ctk cdi generate --output=$spec" >/dev/null ||
|
||||
{ echo "ygg-gpu: nvidia-ctk cdi generate failed" >&2; exit 1; }
|
||||
fi
|
||||
fi
|
||||
do_check
|
||||
Executable
+312
@@ -0,0 +1,312 @@
|
||||
#!/usr/bin/env bash
|
||||
# Start, stop and check the local model server: llama-server (router mode) and the model gate in one container, on
|
||||
# the factory's engine, with the GPU (yggdrasil docs/2-engineering/specs/2026-10-25-local-models.md, 3.4 and 3.10).
|
||||
# ygg-models.sh [--models FILE] [--folder DIR] start build the image if its tag is missing, create the internal network if it is
|
||||
# absent, write the preset into the state folder, run the container, wait
|
||||
# until the server answers
|
||||
# ygg-models.sh [--models FILE] [--folder DIR] stop stop and remove the container; the network and the image stay
|
||||
# ygg-models.sh [--models FILE] [--folder DIR] status print `running on http://127.0.0.1:<host_port>, loaded: <name|none>` and
|
||||
# exit 0; print `not running` and exit 3 when the container is absent or stopped
|
||||
# ygg-models.sh [--models FILE] [--folder DIR] load NAME load the model and wait until it is loaded
|
||||
# ygg-models.sh [--models FILE] [--folder DIR] template NAME with the server running and NAME loaded, read the
|
||||
# model's chat template from the server (GET /props?model=NAME), replace the
|
||||
# line that refuses a later system message with a system turn (refused
|
||||
# unless that line is found exactly once) and save it as
|
||||
# <WEIGHTS>/chat-template.jinja; then uncomment `chat-template-file:
|
||||
# /models/chat-template.jinja` in the list's args, and stop and start
|
||||
# DIR (default $HOME/ygg-it) holds factory.yaml, which names the engine (sandbox.engine): it is passed to ygg-engine.sh
|
||||
# and ygg-gpu.sh as --folder, and `ygg models server` and `ygg models build` read DIR/factory.yaml. The engine is a
|
||||
# Podman machine that sees the host's files only through factory.yaml's `sandbox.mounts`: both the weights and the
|
||||
# state folder of the list must be under a folder of those mounts (for the founder's setup, under ~/ygg-it), and
|
||||
# `ygg models server --config` refuses a folder that none covers (its error names sandbox.mounts).
|
||||
# FILE is the operator's model list (default ${XDG_CONFIG_HOME:-$HOME/.config}/ygg/models.yaml; an example is
|
||||
# scripts/testdata/models/models.example.yaml). This script never parses it: `ygg models server --config
|
||||
# DIR/factory.yaml FILE` prints the server's eight lines NAME=, NETWORK=, PORT=, HOST_PORT=, WEIGHTS=, STATE=,
|
||||
# ENGINE_WEIGHTS=, ENGINE_STATE= (read as data, never evaluated; the last two are the folders as the engine sees
|
||||
# them, mapped through sandbox.mounts; this script checks and writes the host ones), and
|
||||
# `ygg models preset FILE` prints the preset, which `start` writes to <STATE>/preset.ini.
|
||||
# The container: named NAME, on NETWORK alone (an internal network), --device nvidia.com/gpu=all, --cap-drop=ALL and
|
||||
# --security-opt=no-new-privileges, the engine-side weights at /models and state at /etc/ygg-models (both read-only), 127.0.0.1:<HOST_PORT> published to 8080 (the only
|
||||
# published port), and YGG_MODELGATE_PORT=<PORT> for the gate. `start` first runs ygg-gpu.sh check: a stale CDI spec
|
||||
# is set up again by ygg-gpu.sh setup; any other missing state stops and names `scripts/ygg-gpu.sh setup`. The first
|
||||
# image build is long (CUDA). When the container exits while starting, `start` prints the end of its log: a bad
|
||||
# preset key stops the router.
|
||||
# Exit codes: 0 done; 1 failed; 2 bad usage, or the engine does not answer; 3 (status) not running.
|
||||
# Every engine call goes through ygg-engine.sh run, which sets the engine environment; this script never sets
|
||||
# CONTAINER_HOST and never starts, stops or restarts the engine itself.
|
||||
# Environment: YGG_CMD (ygg; the first on PATH), YGG_ENGINE_SH and YGG_GPU_SH (the scripts next to this one),
|
||||
# YGG_MODELS_START_TIMEOUT (120 seconds for the server to answer after `start`), YGG_MODELS_TIMEOUT (300 seconds for a
|
||||
# load), YGG_MODELS_POLL (1 second between polls).
|
||||
set -euo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ygg="${YGG_CMD:-ygg}"
|
||||
engine_sh="${YGG_ENGINE_SH:-$HERE/ygg-engine.sh}"
|
||||
gpu_sh="${YGG_GPU_SH:-$HERE/ygg-gpu.sh}"
|
||||
start_timeout="${YGG_MODELS_START_TIMEOUT:-120}"
|
||||
load_timeout="${YGG_MODELS_TIMEOUT:-300}"
|
||||
poll="${YGG_MODELS_POLL:-1}"
|
||||
|
||||
usage() { echo "usage: ygg-models.sh [--models FILE] [--folder DIR] start|stop|status|load NAME|template NAME" >&2; exit 2; }
|
||||
die() { echo "ygg-models: $*" >&2; exit 1; }
|
||||
list="${XDG_CONFIG_HOME:-$HOME/.config}/ygg/models.yaml"
|
||||
dir="$HOME/ygg-it"
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--models) [ $# -ge 2 ] || usage; list="$2"; shift 2 ;;
|
||||
--folder) [ $# -ge 2 ] || usage; dir="$2"; shift 2 ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
[ $# -ge 1 ] || usage
|
||||
cmd="$1"; shift
|
||||
case "$cmd" in
|
||||
start | stop | status) [ $# -eq 0 ] || usage ;;
|
||||
load | template) [ $# -eq 1 ] || usage ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
|
||||
# The server's lines are data: read by key, never evaluated, so a path may hold spaces.
|
||||
name="" network="" port="" host_port="" weights="" state="" engine_weights="" engine_state=""
|
||||
server_lines="$("$ygg" models server --config "$dir/factory.yaml" "$list")" || exit 1
|
||||
while IFS='=' read -r key value; do
|
||||
case "$key" in
|
||||
NAME) name="$value" ;;
|
||||
NETWORK) network="$value" ;;
|
||||
PORT) port="$value" ;;
|
||||
HOST_PORT) host_port="$value" ;;
|
||||
WEIGHTS) weights="$value" ;;
|
||||
STATE) state="$value" ;;
|
||||
ENGINE_WEIGHTS) engine_weights="$value" ;;
|
||||
ENGINE_STATE) engine_state="$value" ;;
|
||||
esac
|
||||
done <<<"$server_lines"
|
||||
for v in name network port host_port weights state engine_weights engine_state; do
|
||||
[ -n "${!v}" ] || die "ygg models server $list did not print ${v^^}"
|
||||
done
|
||||
url="http://127.0.0.1:$host_port"
|
||||
|
||||
pm() { "$engine_sh" run --folder "$dir" -- podman "$@"; }
|
||||
|
||||
# container_state: print absent, stopped or running. The name is matched exactly: podman also resolves a name as the
|
||||
# prefix of a container ID, so a name such as `cafe` could otherwise reach another container. An engine that does not
|
||||
# answer ends the script with exit 2.
|
||||
container_state() {
|
||||
local names running
|
||||
names="$(pm container ls -a --filter "name=^$name\$" --format '{{.Names}}')" || exit 2
|
||||
printf '%s\n' "$names" | grep -qx -F -- "$name" || { echo absent; return 0; }
|
||||
running="$(pm container inspect -f '{{.State.Running}}' "$name")" || exit 2
|
||||
if [ "$running" = true ]; then echo running; else echo stopped; fi
|
||||
}
|
||||
|
||||
# exists KIND REF: exit 0 when `podman KIND exists REF` says so, 1 when it says no; any other answer is an engine
|
||||
# error, and ends the script with exit 2 (it is not a reason to build or create).
|
||||
exists() {
|
||||
local rc=0
|
||||
pm "$1" exists "$2" >/dev/null || rc=$?
|
||||
case "$rc" in 0 | 1) return "$rc" ;; *) echo "ygg-models: podman $1 exists $2 failed (exit $rc)" >&2; exit 2 ;; esac
|
||||
}
|
||||
|
||||
# http METHOD PATH [BODY]: sets code and body (the answer's text). A server that does not answer sets code to 000.
|
||||
code="" body=""
|
||||
http() {
|
||||
local f args=(-sS -m 30)
|
||||
f="$(mktemp)"
|
||||
[ "$1" = GET ] || args+=(-X "$1" -H 'Content-Type: application/json' -d "$3")
|
||||
code="$(curl "${args[@]}" -o "$f" -w '%{http_code}' "$url$2" 2>/dev/null)" || code=000
|
||||
body="$(cat "$f")"
|
||||
rm -f -- "$f"
|
||||
}
|
||||
|
||||
# The body holds raw newlines inside strings, which a strict JSON parser refuses: parse it tolerantly.
|
||||
# loaded: the name of the loaded model, or none. entry NAME: absent, or the entry's status (a word; `failed N` when
|
||||
# the load failed with exit code N).
|
||||
parse='
|
||||
import json, sys
|
||||
d = json.loads(sys.stdin.read(), strict=False)
|
||||
entries = d.get("data") or []
|
||||
if sys.argv[1] == "loaded":
|
||||
names = [e.get("id") for e in entries if (e.get("status") or {}).get("value") == "loaded"]
|
||||
print(names[0] if names else "none")
|
||||
else:
|
||||
for e in entries:
|
||||
if e.get("id") == sys.argv[2]:
|
||||
s = e.get("status") or {}
|
||||
if s.get("failed"):
|
||||
print("failed", s.get("exit_code", "unknown"))
|
||||
else:
|
||||
print(s.get("value") or "unknown")
|
||||
break
|
||||
else:
|
||||
print("absent")
|
||||
'
|
||||
|
||||
# The model's template refuses a system message after the first (Claude Code sends one). This renders it as a system
|
||||
# turn, and refuses a template that does not hold the refusing line exactly once. Reads /props on stdin, writes argv[1].
|
||||
fix_template='
|
||||
import json, sys
|
||||
t = json.loads(sys.stdin.read(), strict=False)["chat_template"]
|
||||
old = "{{- raise_exception(\x27System message must be at the beginning.\x27) }}"
|
||||
new = "{{- \x27<|im_start|>system\\n\x27 + content + \x27<|im_end|>\\n\x27 }}"
|
||||
if t.count(old) == 0 and t.count(new) >= 1:
|
||||
print("ALREADY")
|
||||
sys.exit(0)
|
||||
if t.count(old) != 1:
|
||||
sys.exit("ygg-models: did not find the line that refuses later system messages in the chat template exactly once; check the template by hand")
|
||||
open(sys.argv[1], "w").write(t.replace(old, new))
|
||||
'
|
||||
|
||||
case "$cmd" in
|
||||
status)
|
||||
cstate="$(container_state)" || exit $?
|
||||
if [ "$cstate" != running ]; then echo "not running"; exit 3; fi
|
||||
http GET /models
|
||||
loaded=unknown
|
||||
[ "$code" != 200 ] || loaded="$(printf '%s' "$body" | python3 -I -c "$parse" loaded 2>/dev/null)" || loaded=unknown
|
||||
echo "running on $url, loaded: $loaded"
|
||||
;;
|
||||
stop)
|
||||
cstate="$(container_state)" || exit $?
|
||||
if [ "$cstate" = absent ]; then echo "not running"; exit 0; fi
|
||||
pm stop -t 30 "$name" >/dev/null || true
|
||||
pm rm -f "$name" >/dev/null
|
||||
echo "stopped $name"
|
||||
;;
|
||||
template)
|
||||
model="$1"
|
||||
[[ "$model" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || die "the model name '$model' is not a plain name"
|
||||
cstate="$(container_state)" || exit $?
|
||||
[ "$cstate" = running ] || die "the model server is not running; start it: scripts/ygg-models.sh start"
|
||||
[ -d "$weights" ] || die "the weights folder $weights does not exist"
|
||||
http GET /models
|
||||
[ "$code" = 200 ] || die "model $model: GET /models answered $code at $url"
|
||||
st="$(printf '%s' "$body" | python3 -I -c "$parse" entry "$model")" || die "model $model: the model list is not JSON"
|
||||
case "$st" in
|
||||
absent) die "model $model: the model server does not list it" ;;
|
||||
loaded) ;;
|
||||
*) die "model $model is not loaded (status $st); load it first: scripts/ygg-models.sh load $model" ;;
|
||||
esac
|
||||
# The router forwards GET /props?model=NAME to the loaded model's server.
|
||||
http GET "/props?model=$model"
|
||||
[ "$code" = 200 ] || die "model $model: GET /props?model=$model answered $code at $url"
|
||||
tmp="$weights/chat-template.jinja.tmp"
|
||||
res="$(printf '%s' "$body" | python3 -I -c "$fix_template" "$tmp")" || { rm -f -- "$tmp"; exit 1; }
|
||||
if [ "$res" = ALREADY ]; then
|
||||
rm -f -- "$tmp"
|
||||
echo "model $model: the fix is already in place (the server's template has the system turn); nothing saved"
|
||||
exit 0
|
||||
fi
|
||||
mv -f "$tmp" "$weights/chat-template.jinja"
|
||||
echo "saved $weights/chat-template.jinja; in the list, uncomment chat-template-file: /models/chat-template.jinja, then:"
|
||||
echo " scripts/ygg-models.sh stop && scripts/ygg-models.sh start"
|
||||
;;
|
||||
load)
|
||||
model="$1"
|
||||
cstate="$(container_state)" || exit $?
|
||||
[ "$cstate" = running ] || die "the model server is not running; start it: scripts/ygg-models.sh start"
|
||||
deadline=$((SECONDS + load_timeout))
|
||||
st=""
|
||||
sent=""
|
||||
while :; do
|
||||
http GET /models
|
||||
[ "$code" = 200 ] || die "model $model: GET /models answered $code at $url (start the server: scripts/ygg-models.sh start)"
|
||||
st="$(printf '%s' "$body" | python3 -I -c "$parse" entry "$model")" || die "model $model: the model list is not JSON"
|
||||
case "$st" in
|
||||
absent) die "model $model: the model server does not list it" ;;
|
||||
loaded) echo "loaded $model"; exit 0 ;;
|
||||
esac
|
||||
# The router keeps `failed: true` until a new load attempt, so a model that is not loaded is asked to load
|
||||
# (as odin's Ready does); a failure counts only after that.
|
||||
if [ -z "$sent" ]; then
|
||||
http POST /models/load "$(python3 -I -c 'import json,sys; print(json.dumps({"model": sys.argv[1]}, separators=(",", ":")))' "$model")"
|
||||
case "$code" in
|
||||
200) ;;
|
||||
400) case "$body" in *"model is already running"*) ;; *) die "model $model: the load request answered 400: $body" ;; esac ;;
|
||||
*) die "model $model: the load request answered $code: $body" ;;
|
||||
esac
|
||||
sent=1
|
||||
sleep "$poll"
|
||||
continue
|
||||
fi
|
||||
case "$st" in failed*) die "model $model: the load failed, exit_code ${st#failed }" ;; esac
|
||||
[ "$SECONDS" -lt "$deadline" ] || die "model $model: not loaded within ${load_timeout}s (status $st, wanted loaded)"
|
||||
sleep "$poll"
|
||||
done
|
||||
;;
|
||||
start)
|
||||
# Names go to podman as they are: a network such as host would ignore -p, and a name that starts with - is a flag.
|
||||
for v in "$name" "$network"; do
|
||||
[[ "$v" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]] || die "the name '$v' in $list is not a plain container or network name"
|
||||
done
|
||||
case "$network" in
|
||||
host | none | bridge | pasta | private | slirp4netns) die "the network '$network' in $list is a reserved podman network, not an internal one; name your own" ;;
|
||||
esac
|
||||
[ -d "$weights" ] || die "the weights folder $weights does not exist (weights in $list); put the model files there"
|
||||
tag="$("$ygg" models image)" || exit 1
|
||||
preset="$("$ygg" models preset "$list")" || exit 1
|
||||
# The GPU: the engine resolves the CDI device before the container exists.
|
||||
rc=0
|
||||
gpu_out="$("$gpu_sh" check --folder "$dir" 2>&1)" || rc=$?
|
||||
case "$rc" in
|
||||
0) printf '%s\n' "$gpu_out" | tail -n 1 ;;
|
||||
3)
|
||||
if printf '%s\n' "$gpu_out" | grep -qx 'missing: stale spec'; then
|
||||
echo "ygg-models: the CDI spec is stale; setting it up again"
|
||||
"$gpu_sh" setup --folder "$dir" || die "scripts/ygg-gpu.sh setup failed"
|
||||
else
|
||||
printf '%s\n' "$gpu_out" >&2
|
||||
die "the GPU is not ready for the engine; run scripts/ygg-gpu.sh setup"
|
||||
fi ;;
|
||||
*) printf '%s\n' "$gpu_out" >&2; exit "$rc" ;;
|
||||
esac
|
||||
cstate="$(container_state)" || exit $?
|
||||
case "$cstate" in
|
||||
running) echo "already running on $url (stop first to apply list changes: scripts/ygg-models.sh stop)"; exit 0 ;;
|
||||
stopped) pm rm -f "$name" >/dev/null ;;
|
||||
esac
|
||||
if ! exists image "$tag"; then
|
||||
echo "ygg-models: building the image $tag (the first build takes a long time: CUDA)"
|
||||
"$engine_sh" run --folder "$dir" -- "$ygg" models build --config "$dir/factory.yaml" || die "ygg models build failed"
|
||||
fi
|
||||
if exists network "$network"; then
|
||||
[ "$(pm network inspect -f '{{.Internal}}' "$network")" = true ] ||
|
||||
die "the network $network exists but is not internal; remove it (podman network rm $network) or name another in $list"
|
||||
else
|
||||
pm network create --internal "$network" >/dev/null
|
||||
fi
|
||||
mkdir -p "$state"
|
||||
printf '%s\n' "$preset" >"$state/preset.ini.tmp"
|
||||
mv -f "$state/preset.ini.tmp" "$state/preset.ini"
|
||||
rc=0
|
||||
# The engine sees the folders at their engine-side paths (sandbox.mounts); the host paths are for this script.
|
||||
# ,Z relabels the mounts for SELinux, as the sandbox driver does for its own read-only mounts.
|
||||
out="$(pm run -d --name "$name" --network "$network" --device nvidia.com/gpu=all \
|
||||
--cap-drop=ALL --security-opt=no-new-privileges \
|
||||
-v "$engine_weights:/models:ro,Z" -v "$engine_state:/etc/ygg-models:ro,Z" \
|
||||
-p "127.0.0.1:$host_port:8080" -e "YGG_MODELGATE_PORT=$port" "$tag" 2>&1)" || rc=$?
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
printf '%s\n' "$out" >&2
|
||||
case "$out" in
|
||||
*"unresolvable CDI devices"*) die "the engine cannot resolve the GPU; run scripts/ygg-gpu.sh setup" ;;
|
||||
esac
|
||||
exit 1
|
||||
fi
|
||||
deadline=$((SECONDS + start_timeout))
|
||||
while :; do
|
||||
cstate="$(container_state)" || exit $?
|
||||
if [ "$cstate" != running ]; then
|
||||
echo "ygg-models: the container exited while starting; the end of its log:" >&2
|
||||
pm logs --tail 20 "$name" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
http GET /models
|
||||
[ "$code" != 200 ] || break
|
||||
if [ "$SECONDS" -ge "$deadline" ]; then
|
||||
echo "ygg-models: the server did not answer on $url within ${start_timeout}s; the end of its log:" >&2
|
||||
pm logs --tail 20 "$name" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
sleep "$poll"
|
||||
done
|
||||
echo "ready on $url (container $name; log: podman logs $name)"
|
||||
;;
|
||||
esac
|
||||
+55
-16
@@ -7,20 +7,28 @@
|
||||
# ygg-proof-run.sh --name NAME [--mode key-points|unattended|step-by-step] [--planner AGENT:MODEL]
|
||||
# [--implementer AGENT:MODEL] [--reviewer AGENT:MODEL]
|
||||
# [--retro-agent AGENT] [--retro-model MODEL]
|
||||
# [--reviewer-reasoning low|medium|high|off] [--run-turns N] [--run-tokens N] [--run-usd N]
|
||||
# [--models FILE] [--reviewer-reasoning low|medium|high|off] [--run-turns N] [--run-tokens N] [--run-usd N]
|
||||
# [--day-turns N] [--day-tokens N] [--day-usd N] [--baseline RUN_ID] [--dry-run] [--build]
|
||||
# [--folder DIR] [--env FILE] [--tasks DIR] [--suites DIR] [--yggdrasil DIR] [--wall TIME] [--clip] [--any-ygg]
|
||||
# ygg-proof-run.sh --continue RUN_ID (--approve GATE [--note TEXT | --decisions FILE] |
|
||||
# --reject GATE (--reason TEXT | --decisions FILE) | --resume)
|
||||
# [--baseline RUN_ID] [--dry-run] [--folder DIR] [--env FILE] [--wall TIME] [--clip] [--any-ygg]
|
||||
# AGENT is claude-code or opencode; an opencode MODEL is openrouter/<model>; a MODEL has only A-Za-z0-9._/:-
|
||||
# because it goes into a YAML line. Defaults: key-points; planner and implementer claude-code:sonnet; reviewer
|
||||
# AGENT is claude-code or opencode; an opencode MODEL is openrouter/<model> or local/<name> (a model of the operator's
|
||||
# model list, served by scripts/ygg-models.sh); a MODEL has only A-Za-z0-9._/:- because it goes into a YAML line. Defaults: key-points; planner and implementer claude-code:sonnet; reviewer
|
||||
# opencode:openrouter/qwen/qwen3.8-27b with --reviewer-reasoning medium (the reviewer role's reasoning effort: a
|
||||
# Qwen reviewer at the default high stalled a whole session in the rehearsal; a claude-code agent ignores the
|
||||
# setting, and the line is written for every agent); the retrospective role claude-code:opus (--retro-agent and
|
||||
# --retro-model; the role that writes the run's retrospective); a run 1,500 turns, 12,000,000 tokens, $5; a day 2,000
|
||||
# turns, 24,000,000 tokens, $10; --wall 8h; DIR $HOME/ygg-it; FILE $HOME/.config/ouroboric/factory.env;
|
||||
# --tasks ../mimir; --suites $HOME/ygg-it/hidden-suites; --yggdrasil the umbrella beside this repo.
|
||||
# A local role (opencode:local/<name>, for the planner, the implementer, the reviewer or the retrospective) needs
|
||||
# --models FILE, the model list: the config then has `models: { file: FILE }`. FILE must exist; a relative path is made
|
||||
# absolute from the caller's folder (the config takes a clean absolute path). A local role without --models is refused
|
||||
# before anything is made, and so is --models with no local role; so are roles on two different local models (the
|
||||
# server holds one). A local role sets no `reasoning` (ygg refuses it), so a
|
||||
# local reviewer gets no reasoning line whatever --reviewer-reasoning says, and the plan says so. Only an
|
||||
# opencode role on openrouter/... is paid per token: only then does the config have usd lines and the credentials
|
||||
# file need OPENROUTER_API_KEY (claude-code needs CLAUDE_CODE_OAUTH_TOKEN; a local role needs no credential).
|
||||
# The task is client-gallery-lite (YGG_PROOF_TASK names another, for tests); its suite is the `suite` key of its
|
||||
# task.toml.
|
||||
# In order, it
|
||||
@@ -31,11 +39,11 @@
|
||||
# 3. checks the suites folder with `test -d SUITES/TASK/SUITE` and nothing more: it is never listed, read or
|
||||
# searched, and neither it nor the tasks folder is inside the other or inside the project;
|
||||
# 4. checks the credentials file WITHOUT reading it, for the adapters the roles name only: claude-code needs
|
||||
# CLAUDE_CODE_OAUTH_TOKEN, opencode needs OPENROUTER_API_KEY, each on one line with a value after the equals
|
||||
# CLAUDE_CODE_OAUTH_TOKEN, opencode on openrouter needs OPENROUTER_API_KEY, each on one line with a value after the equals
|
||||
# sign (grep -c counts the lines; no value is printed or kept);
|
||||
# 5. makes DIR/proof-NAME-TIME/project (a git repository, umbrella.yaml with root: true) and
|
||||
# DIR/proof-NAME-TIME/factory.yaml, filled from scripts/testdata/proof/factory.proof.example.yaml (a "usd"
|
||||
# line only when a role is an opencode role, which pays per token);
|
||||
# line only when a role is an opencode role on openrouter, which pays per token);
|
||||
# 6. runs `ygg doctor` on that config and prints the plan: what would run.
|
||||
# --dry-run stops there with exit 0. Without it (v0-T291) the script then
|
||||
# 7. with --build runs `ygg sandbox build --all --config C`; runs `ygg run --project P --config C --graph software
|
||||
@@ -82,7 +90,7 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source-path=SCRIPTDIR source=ygg-build-facts.sh
|
||||
. "$HERE/ygg-build-facts.sh"
|
||||
usage() {
|
||||
echo "usage: ygg-proof-run.sh --name NAME [--mode MODE] [--planner|--implementer|--reviewer AGENT:MODEL] [--retro-agent AGENT] [--retro-model MODEL] [--reviewer-reasoning low|medium|high|off] [--run-turns|--run-tokens|--run-usd|--day-turns|--day-tokens|--day-usd N] [--baseline RUN_ID] [--dry-run] [--build] [--folder DIR] [--env FILE] [--tasks DIR] [--suites DIR] [--yggdrasil DIR] [--wall TIME] [--clip] [--any-ygg]" >&2
|
||||
echo "usage: ygg-proof-run.sh --name NAME [--mode MODE] [--planner|--implementer|--reviewer AGENT:MODEL] [--models FILE] [--retro-agent AGENT] [--retro-model MODEL] [--reviewer-reasoning low|medium|high|off] [--run-turns|--run-tokens|--run-usd|--day-turns|--day-tokens|--day-usd N] [--baseline RUN_ID] [--dry-run] [--build] [--folder DIR] [--env FILE] [--tasks DIR] [--suites DIR] [--yggdrasil DIR] [--wall TIME] [--clip] [--any-ygg]" >&2
|
||||
echo " ygg-proof-run.sh --continue RUN_ID (--approve GATE [--note TEXT | --decisions FILE] | --reject GATE (--reason TEXT | --decisions FILE) | --resume)" >&2
|
||||
echo " [--baseline RUN_ID] [--dry-run] [--folder DIR] [--env FILE] [--wall TIME] [--clip] [--any-ygg]" >&2
|
||||
exit 2
|
||||
@@ -93,7 +101,7 @@ fail() { echo "proof-run: $*" >&2; exit 2; }
|
||||
# umask, as in the smoke scripts: no umask is set for the whole script.
|
||||
private() { (umask 077 && : >>"$1") && chmod 600 "$1"; } # FILE: made private; what it holds stays
|
||||
put() { (umask 077 && printf '%s\n' "$2" >"$1"); } # FILE TEXT: a small control file, private
|
||||
name="" mode=key-points dry=0 build=0 clip=0 baseline="" wall=8h any_ygg=0
|
||||
models="" name="" mode=key-points dry=0 build=0 clip=0 baseline="" wall=8h any_ygg=0
|
||||
# --continue; setup_flags are the setup flags given (a continued run keeps its config)
|
||||
cont="" gate_in="" reject_in="" reason="" reason_set=0 note="" note_set=0 resume=0 setup_flags=""
|
||||
decisions="" decisions_set=0
|
||||
@@ -104,7 +112,7 @@ dir="$HOME/ygg-it" envfile="$HOME/.config/ouroboric/factory.env" tasks="$HERE/..
|
||||
suites="$HOME/ygg-it/hidden-suites" ygg_dir="$HERE/../../yggdrasil"
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--name | --mode | --planner | --implementer | --reviewer | --reviewer-reasoning | --retro-* | --run-* | --day-* | --build | --tasks | --suites | --yggdrasil) setup_flags+=" $1" ;;
|
||||
--name | --mode | --planner | --implementer | --reviewer | --models | --reviewer-reasoning | --retro-* | --run-* | --day-* | --build | --tasks | --suites | --yggdrasil) setup_flags+=" $1" ;;
|
||||
esac
|
||||
case "$1" in
|
||||
--dry-run) dry=1; shift ;;
|
||||
@@ -125,6 +133,7 @@ while [ $# -gt 0 ]; do
|
||||
--planner) [ $# -ge 2 ] || usage; planner="$2"; shift 2 ;;
|
||||
--implementer) [ $# -ge 2 ] || usage; implementer="$2"; shift 2 ;;
|
||||
--reviewer) [ $# -ge 2 ] || usage; reviewer="$2"; shift 2 ;;
|
||||
--models) [ $# -ge 2 ] || usage; models="$2"; shift 2 ;;
|
||||
--reviewer-reasoning) [ $# -ge 2 ] || usage; reviewer_reasoning="$2"; shift 2 ;;
|
||||
--run-turns) [ $# -ge 2 ] || usage; run_turns="$2"; shift 2 ;;
|
||||
--run-tokens) [ $# -ge 2 ] || usage; run_tokens="$2"; shift 2 ;;
|
||||
@@ -206,11 +215,16 @@ role_ok() { # FLAG VALUE: sets agent_of and model_of
|
||||
[[ $model =~ ^[A-Za-z0-9._/:-]+$ && $model != *: ]] || fail "$flag has a MODEL with characters outside A-Za-z0-9._/:- or ends in a colon"
|
||||
case "$agent" in
|
||||
claude-code) ;;
|
||||
opencode) [[ $model == openrouter/?* ]] || fail "$flag: an opencode MODEL is openrouter/<model>, such as opencode:openrouter/qwen/qwen3.8-27b" ;;
|
||||
opencode)
|
||||
if [[ $model == local/* ]]; then
|
||||
[[ ${model#local/} =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]] || fail "$flag: a local MODEL is local/<name>, with a name of a-z0-9._- that starts with a-z0-9 (at most 64), such as opencode:local/qwen3.8-27b"
|
||||
else
|
||||
[[ $model == openrouter/?* ]] || fail "$flag: an opencode MODEL is openrouter/<model> or local/<name>, such as opencode:openrouter/qwen/qwen3.8-27b"
|
||||
fi ;;
|
||||
*) fail "$flag is AGENT:MODEL, with AGENT claude-code or opencode, not '$agent'" ;;
|
||||
esac
|
||||
}
|
||||
roles_ok() { # sets paid, need_claude and need_router
|
||||
roles_ok() { # sets paid, need_claude, need_router and need_local
|
||||
role_ok --planner "$planner"; role_ok --implementer "$implementer"; role_ok --reviewer "$reviewer"
|
||||
# A proof folder from before the retrospective role has none: retro stays empty and the role is left out.
|
||||
retro=""
|
||||
@@ -219,12 +233,31 @@ roles_ok() { # sets paid, need_claude and need_router
|
||||
retro="$retro_agent:$retro_model"
|
||||
role_ok --retro-agent/--retro-model "$retro"
|
||||
fi
|
||||
paid=0 need_claude=0 need_router=0
|
||||
paid=0 need_claude=0 need_router=0 need_local=0
|
||||
local_models=""
|
||||
for r in "$planner" "$implementer" "$reviewer" ${retro:+"$retro"}; do
|
||||
case "${r%%:*}" in claude-code) need_claude=1 ;; opencode) need_router=1; paid=1 ;; esac
|
||||
case "${r%%:*}" in
|
||||
claude-code) need_claude=1 ;;
|
||||
opencode) if [[ ${r#*:} == local/* ]]; then need_local=1; local_models+="${r#*:}"$'\n'; else need_router=1; paid=1; fi ;;
|
||||
esac
|
||||
done
|
||||
# The server holds one model at a time (spec 5.2.1): roles on two local models are refused before anything is made.
|
||||
[ "$(printf '%s' "$local_models" | sort -u | grep -c .)" -le 1 ] || fail "roles on two different local models ($(printf '%s' "$local_models" | sort -u | paste -sd' ')): the local model server holds one; name the same local/<name> for every local role"
|
||||
# A local reviewer sets no reasoning (ygg refuses it): --reviewer-reasoning does not apply to it.
|
||||
reviewer_is_local=0
|
||||
[[ ${reviewer#*:} != local/* ]] || reviewer_is_local=1
|
||||
}
|
||||
[ -n "$cont" ] || roles_ok
|
||||
if [ -z "$cont" ]; then
|
||||
# The model list: needed by a local role, and only then. The config takes a clean absolute path.
|
||||
if [ "$need_local" -eq 1 ] && [ -z "$models" ]; then fail "a role is on a local model (opencode:local/<name>): --models FILE, the model list, is needed"; fi
|
||||
if [ "$need_local" -eq 0 ] && [ -n "$models" ]; then fail "--models goes with a role on a local model (opencode:local/<name>); no role is on one"; fi
|
||||
if [ -n "$models" ]; then
|
||||
[ -f "$models" ] || fail "--models $models: no such file"
|
||||
models="$(readlink -f -- "$models")"
|
||||
safe_path --models "$models"
|
||||
fi
|
||||
fi
|
||||
|
||||
# The engine, the tools.
|
||||
export PATH="$dir/dc/node_modules/.bin:$PATH"
|
||||
@@ -332,7 +365,7 @@ fi
|
||||
names=()
|
||||
[ "$need_claude" -eq 0 ] || names+=(CLAUDE_CODE_OAUTH_TOKEN)
|
||||
[ "$need_router" -eq 0 ] || names+=(OPENROUTER_API_KEY)
|
||||
for n_ in "${names[@]}"; do
|
||||
for n_ in ${names[@]+"${names[@]}"}; do
|
||||
n="$(grep -c -E "^(export[[:space:]]+)?${n_}=[^[:space:]]" "$envfile" || true)"
|
||||
[ "$n" = 1 ] || fail "$envfile has $n line(s) that set $n_ with a value; the roles need exactly 1"
|
||||
done
|
||||
@@ -354,17 +387,21 @@ if [ -z "$cont" ]; then
|
||||
fill MODE "$mode"
|
||||
fill PLANNER_AGENT "${planner%%:*}"; fill PLANNER_MODEL "${planner#*:}"
|
||||
fill IMPLEMENTER_AGENT "${implementer%%:*}"; fill IMPLEMENTER_MODEL "${implementer#*:}"
|
||||
fill REVIEWER_AGENT "${reviewer%%:*}"; fill REVIEWER_MODEL "${reviewer#*:}"; fill REVIEWER_REASONING "$reviewer_reasoning"
|
||||
fill REVIEWER_AGENT "${reviewer%%:*}"; fill REVIEWER_MODEL "${reviewer#*:}"
|
||||
if [ "$reviewer_is_local" -eq 1 ]; then fill REVIEWER_REASONING ""; else fill REVIEWER_REASONING ", reasoning: $reviewer_reasoning"; fi
|
||||
fill RETRO_AGENT "$retro_agent"; fill RETRO_MODEL "$retro_model"
|
||||
fill RUN_TOKENS "$run_tokens"; fill RUN_TURNS "$run_turns"; fill RUN_USD "$usd_run"
|
||||
fill DAY_TOKENS "$day_tokens"; fill DAY_TURNS "$day_turns"; fill DAY_USD "$usd_day"
|
||||
fill ENGINE "$engine"; fill FOLDER "$dir_abs"; fill BIND "${YGG_BIND:-/mnt/wsl/ygg-bind}"
|
||||
fill REALMS "$ygg_dir"; fill GRAPHS "$ygg_dir/voluspa"
|
||||
fill TASKS "$tasks"; fill SUITES "$suites"
|
||||
if [ "$need_local" -eq 1 ]; then text+=$'\nmodels:\n'" file: $models"; fi
|
||||
printf '%s\n' "$text" >"$work/factory.yaml"
|
||||
if grep -q '@[A-Z_]*@' "$work/factory.yaml"; then fail "$work/factory.yaml still has a place to fill: $(grep -o '@[A-Z_]*@' "$work/factory.yaml" | sort -u | tr '\n' ' ')"; fi
|
||||
echo "proof-run: made $work"
|
||||
echo "proof-run: the task is $task, suite $suite; the brief is the frozen one (SHA-256 $have)"
|
||||
[ "$reviewer_is_local" -eq 0 ] || echo "proof-run: the reviewer is a local model, so its reasoning is left out of the config (--reviewer-reasoning does not apply)"
|
||||
[ "$need_local" -eq 0 ] || echo "proof-run: the model list is $models (models.file)"
|
||||
[ "$paid" -eq 1 ] || echo "proof-run: no role is paid per token, so the config has no usd line"
|
||||
# What --continue needs to know about this proof run, one "key value" a line; every value was checked above.
|
||||
private "$work/proof.meta"
|
||||
@@ -404,7 +441,9 @@ build_cmd="$(q "${build_argv[@]}")"
|
||||
timeout_argv=(timeout --signal=INT --kill-after=2m "$wall")
|
||||
reasoning_note="" # for an old folder, why the reasoning is unset
|
||||
[ "$reviewer_reasoning" != unset ] || reasoning_note=": a folder from before --reviewer-reasoning"
|
||||
echo "proof-run: the plan: mode $mode; planner $planner, implementer $implementer, reviewer $reviewer (reasoning $reviewer_reasoning${reasoning_note})${retro:+; retrospective $retro}"
|
||||
reasoning_desc="reasoning $reviewer_reasoning${reasoning_note}"
|
||||
[ "$reviewer_is_local" -eq 0 ] || reasoning_desc="no reasoning: a local model sets none"
|
||||
echo "proof-run: the plan: mode $mode; planner $planner, implementer $implementer, reviewer $reviewer ($reasoning_desc)${retro:+; retrospective $retro}"
|
||||
echo "proof-run: the caps in $work/factory.yaml:"
|
||||
{ grep -E '^ per_(run|day):' "$work/factory.yaml" || true; } | sed 's/^/ /'
|
||||
[ -z "$cont_what" ] || echo "proof-run: the continuation: $cont_what"
|
||||
@@ -536,7 +575,7 @@ step() { # TITLE CMD...: a heading, then the command's output; its exit code is
|
||||
}
|
||||
evidence_part() {
|
||||
echo "# proof run $name: run $id, exit code $rc${cont_what:+ (continued: $cont_what)}"
|
||||
echo "mode $mode; planner $planner, implementer $implementer, reviewer $reviewer (reasoning $reviewer_reasoning); brief SHA-256 $have"
|
||||
echo "mode $mode; planner $planner, implementer $implementer, reviewer $reviewer ($reasoning_desc); brief SHA-256 $have"
|
||||
echo
|
||||
case "$rc" in
|
||||
0 | 3 | 4 | 5) ;;
|
||||
|
||||
+44
-10
@@ -2,7 +2,7 @@
|
||||
# Run the real-agent smoke (v0-H17): the task smoke-notes built by the real Claude Code and the real OpenCode on
|
||||
# OpenRouter, then graded. IT COSTS MONEY: the caps are in odin/testdata/smoke-grade/factory.real.example.yaml.
|
||||
# ygg-smoke-real.sh [--dry-run] [--build] [--folder DIR] [--env FILE] [--tasks DIR] [--odin DIR] [--wall TIME] [--clip]
|
||||
# [--from-leaf LEAF] [--implementer AGENT:MODEL] [--brief-note FILE] [--any-ygg]
|
||||
# [--from-leaf LEAF] [--implementer AGENT:MODEL] [--models FILE] [--brief-note FILE] [--any-ygg]
|
||||
# The suite is the PUBLIC sample suite of the task (no hidden suite of smoke-notes exists); the agents never mount
|
||||
# it. Needs DIR/factory.yaml (it names the engine; this script never writes it), the credentials file, the
|
||||
# devcontainer CLI 0.89.0, jq and python3, the engine up, the bind mount present, and a checkout of `mimir`. It
|
||||
@@ -10,12 +10,18 @@
|
||||
# build from --odin gives the ygg, its build facts (BINARY.build.txt, written by scripts/ygg-build.sh) must name
|
||||
# the HEAD of the odin checkout and the binary's SHA-256. A ygg with no facts, or other ones, is refused
|
||||
# ("run scripts/ygg-build.sh"); --any-ygg skips the check and says so; a dry run prints the result only;
|
||||
# 2. checks the credentials file WITHOUT reading it: the file exists, and each of the two names
|
||||
# CLAUDE_CODE_OAUTH_TOKEN and OPENROUTER_API_KEY is on one line with a value after it (grep -c counts the
|
||||
# lines; no value is printed or kept);
|
||||
# 2. checks the credentials file WITHOUT reading it: the file exists, and each name the roles need is on one line
|
||||
# with a value after it (grep -c counts the lines; no value is printed or kept). A claude-code role needs
|
||||
# CLAUDE_CODE_OAUTH_TOKEN and an opencode role on openrouter/... needs OPENROUTER_API_KEY; a local role needs
|
||||
# none. The roles are the example's, with --implementer applied. If none is on OpenRouter, the "usd" caps
|
||||
# are taken out of the config (only a paid API bills dollars);
|
||||
# 3. makes DIR/smoke-real-<time> with the project, the public suite and the factory.yaml filled from the
|
||||
# example (its caps are the real ones). With --implementer AGENT:MODEL, its implementer line is replaced
|
||||
# (AGENT is claude-code or opencode; an opencode MODEL is openrouter/<model>), so one role changes (B-110).
|
||||
# (AGENT is claude-code or opencode; an opencode MODEL is openrouter/<model> or local/<name>, a model of the
|
||||
# operator's model list), so one role changes (B-110). A local role needs --models FILE, the model list: the
|
||||
# config then ends with `models:` and ` file: FILE`. FILE must exist; a relative path is made absolute from
|
||||
# the caller's folder. A local role without --models is refused before anything is made, and so is --models
|
||||
# when no role is local, and so are roles on two different local models. A local role sets no `reasoning` (the example's roles have none);
|
||||
# With --from-leaf LEAF, the leaf repos/notes starts from the files of LEAF (without .git) instead of the
|
||||
# pack's template (B-109): the pack is copied to DIR/smoke-real-<time>/pack, LEAF becomes the copy's
|
||||
# template, and every file of LEAF goes in the leaf's first commit. odin writes those files without the
|
||||
@@ -56,9 +62,9 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
. "$HERE/ygg-build-facts.sh"
|
||||
any_ygg=0
|
||||
dir="$HOME/ygg-it" envfile="$HOME/.config/ouroboric/factory.env" tasks="$HERE/../../mimir" wall=90m dry=0 build=0 clip=0
|
||||
leaf="" implementer="" note=""
|
||||
leaf="" implementer="" note="" models=""
|
||||
odin="${YGG_ODIN:-$HERE/../../yggdrasil/odin}"
|
||||
usage() { echo "usage: ygg-smoke-real.sh [--dry-run] [--build] [--folder DIR] [--env FILE] [--tasks DIR] [--odin DIR] [--wall TIME] [--clip] [--from-leaf LEAF] [--implementer AGENT:MODEL] [--brief-note FILE] [--any-ygg]" >&2; exit 2; }
|
||||
usage() { echo "usage: ygg-smoke-real.sh [--dry-run] [--build] [--folder DIR] [--env FILE] [--tasks DIR] [--odin DIR] [--wall TIME] [--clip] [--from-leaf LEAF] [--implementer AGENT:MODEL] [--models FILE] [--brief-note FILE] [--any-ygg]" >&2; exit 2; }
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--dry-run) dry=1; shift ;;
|
||||
@@ -71,6 +77,7 @@ while [ $# -gt 0 ]; do
|
||||
--clip) clip=1; shift ;;
|
||||
--from-leaf) [ $# -ge 2 ] || usage; leaf="$2"; shift 2 ;;
|
||||
--implementer) [ $# -ge 2 ] || usage; implementer="$2"; shift 2 ;;
|
||||
--models) [ $# -ge 2 ] || usage; models="$2"; shift 2 ;;
|
||||
--brief-note) [ $# -ge 2 ] || usage; note="$2"; shift 2 ;;
|
||||
--any-ygg) any_ygg=1; shift ;;
|
||||
*) usage ;;
|
||||
@@ -87,10 +94,10 @@ if [ -n "$implementer" ]; then
|
||||
if [[ $implementer == *:* ]] && [[ $impl_model =~ ^[A-Za-z0-9._/:-]+$ ]]; then
|
||||
case "$impl_agent" in
|
||||
claude-code) ok=1 ;;
|
||||
opencode) [[ $impl_model == openrouter/?* ]] && ok=1 ;;
|
||||
opencode) { [[ $impl_model == openrouter/?* ]] || [[ $impl_model =~ ^local/[a-z0-9][a-z0-9._-]{0,63}$ ]]; } && ok=1 ;;
|
||||
esac
|
||||
fi
|
||||
[ "$ok" = 1 ] || fail "--implementer is AGENT:MODEL, with AGENT claude-code or opencode; an opencode MODEL is openrouter/<model>, such as opencode:openrouter/qwen/qwen3.8-27b"
|
||||
[ "$ok" = 1 ] || fail "--implementer is AGENT:MODEL, with AGENT claude-code or opencode; an opencode MODEL is openrouter/<model> or local/<name>, such as opencode:openrouter/qwen/qwen3.8-27b"
|
||||
fi
|
||||
|
||||
export PATH="$dir/dc/node_modules/.bin:$PATH"
|
||||
@@ -152,7 +159,29 @@ fi
|
||||
# The credentials file: its existence, and the two names. grep -c prints a count and nothing else; the file's
|
||||
# lines are never shown, stored or compared. A name needs one line, with something after the equals sign.
|
||||
[ -f "$envfile" ] || fail "the credentials file $envfile is missing"
|
||||
for name in CLAUDE_CODE_OAUTH_TOKEN OPENROUTER_API_KEY; do
|
||||
# The roles the config will have: the example's, with the implementer given. The names and the model list follow.
|
||||
roles_text="$(sed -n '/^roles:/,/^[^ ]/p' "$example" | grep -E '^ [a-z]+:' || true)"
|
||||
if [ -n "$implementer" ]; then
|
||||
roles_text="$(printf '%s\n' "$roles_text" | grep -v '^ implementer:' || true)"$'\n'" implementer: { agent: $impl_agent, model: $impl_model }"
|
||||
fi
|
||||
need_claude=0 need_router=0 need_local=0
|
||||
grep -q -E 'agent:[[:space:]]*claude-code' <<<"$roles_text" && need_claude=1
|
||||
grep -q -E 'model:[[:space:]]*openrouter/' <<<"$roles_text" && need_router=1
|
||||
grep -q -E 'model:[[:space:]]*local/' <<<"$roles_text" && need_local=1
|
||||
# The server holds one model at a time (spec 5.2.1): roles on two local models are refused before anything is made.
|
||||
local_models="$(grep -o -E 'model:[[:space:]]*local/[^ ,}]*' <<<"$roles_text" | sed -E 's/^model:[[:space:]]*//' | sort -u || true)"
|
||||
[ "$(printf '%s' "$local_models" | grep -c .)" -le 1 ] || fail "roles on two different local models ($(printf '%s' "$local_models" | paste -sd' ')): the local model server holds one; name the same local/<name> for every local role"
|
||||
if [ "$need_local" -eq 1 ] && [ -z "$models" ]; then fail "a role is on a local model (opencode:local/<name>): --models FILE, the model list, is needed"; fi
|
||||
if [ "$need_local" -eq 0 ] && [ -n "$models" ]; then fail "--models goes with a role on a local model (opencode:local/<name>); no role is on one"; fi
|
||||
if [ -n "$models" ]; then
|
||||
[ -f "$models" ] || fail "--models $models: no such file"
|
||||
models="$(readlink -f -- "$models")"
|
||||
[[ $models =~ ^[A-Za-z0-9._/@+-]+$ ]] || fail "--models '$models' has a character outside A-Za-z0-9._/@+- (no spaces); it goes into factory.yaml"
|
||||
fi
|
||||
names=()
|
||||
[ "$need_claude" -eq 0 ] || names+=(CLAUDE_CODE_OAUTH_TOKEN)
|
||||
[ "$need_router" -eq 0 ] || names+=(OPENROUTER_API_KEY)
|
||||
for name in ${names[@]+"${names[@]}"}; do
|
||||
n="$(grep -c -E "^(export[[:space:]]+)?${name}=[^[:space:]]" "$envfile" || true)"
|
||||
[ "$n" = 1 ] || fail "$envfile has $n line(s) that set $name with a value; it needs exactly 1"
|
||||
done
|
||||
@@ -221,6 +250,11 @@ text="${text//@FOLDER@/$dir}"
|
||||
text="${text//@BIND@/$bind}"
|
||||
text="${text//@TASKS@/$grader_tasks}"
|
||||
text="${text//@SUITES@/$work/suites}"
|
||||
if [ "$need_router" -eq 0 ]; then
|
||||
# No role is paid per token: the dollar caps stay out of the config.
|
||||
text="$(sed -E '/^ per_(run|day):/ s/,[[:space:]]*usd:[[:space:]]*[0-9.]+//' <<<"$text")"
|
||||
fi
|
||||
if [ "$need_local" -eq 1 ]; then text+=$'\nmodels:\n'" file: $models"; fi
|
||||
printf '%s\n' "$text" >"$work/factory.yaml"
|
||||
if grep -q '@[A-Z]*@' "$work/factory.yaml"; then fail "$work/factory.yaml still has a place to fill: $(grep -o '@[A-Z]*@' "$work/factory.yaml" | sort -u | tr '\n' ' ')"; fi
|
||||
if [ -n "$implementer" ]; then
|
||||
|
||||
Reference in new issue
Block a user