diff --git a/.github/workflows/aur-deploy.yml b/.github/workflows/aur-deploy.yml index 873b7f42..48835f32 100644 --- a/.github/workflows/aur-deploy.yml +++ b/.github/workflows/aur-deploy.yml @@ -11,10 +11,14 @@ on: required: true type: string +permissions: + contents: read + jobs: deploy: name: Publish k3d-gpu to AUR runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -30,12 +34,29 @@ jobs: echo "version=$REF" >> "$GITHUB_OUTPUT" fi + # Releases build in parallel (one build-and-push queue per version), so an + # older release can reach this deploy after a newer one: AUR never moves back. + # When the AUR RPC cannot be read the deploy goes ahead. + - name: AUR already newer? + id: aur + env: + VER: ${{ steps.ver.outputs.version }} + run: | + cur=$(curl -fsSg --retry 3 --max-time 30 'https://aur.archlinux.org/rpc/v5/info?arg[]=k3d-gpu' \ + | jq -r '.results[0].Version // empty' | cut -d- -f1) || cur= + if [ -n "$cur" ] && [ "$cur" != "$VER" ] && [ "$(printf '%s\n%s\n' "$cur" "$VER" | sort -V | tail -1)" = "$cur" ]; then + echo "AUR has k3d-gpu $cur, newer than $VER; leaving it" + echo "skip=true" >> "$GITHUB_OUTPUT" + fi + - name: Update PKGBUILD + if: steps.aur.outputs.skip != 'true' run: | sed -i "s/^pkgver=.*/pkgver=${{ steps.ver.outputs.version }}/" packaging/aur/PKGBUILD sed -i "s/^pkgrel=.*/pkgrel=1/" packaging/aur/PKGBUILD - name: Publish to AUR + if: steps.aur.outputs.skip != 'true' uses: KSXGitHub/github-actions-deploy-aur@084b0d9b15415bf9cdb65d44dad1efe37a354050 # v4.2.0 with: pkgname: k3d-gpu diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 82cd1cd6..d9ebe3c9 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -1,12 +1,21 @@ name: Auto-release AUR -# Auto-tag a release on changes to packaging/, scripts/, share/ or the Dockerfile, -# and when build-and-push.yml's nightly check bumps upstream pins (dispatch). +# Auto-tag a release when a commit on main changes a path hack/release-paths lists +# (what the packages, the formula and the image ship: packaging/, scripts/, +# share/, the Dockerfile, console/, images.lock and the shipped bench/ and oracle/ +# files), and when build-and-push.yml's nightly check bumps upstream pins +# (dispatch). The push paths: below repeat hack/release-paths (GitHub reads no +# file there); tests/ci_wiring_test.sh checks they are equal. # Tag format: aur-vX.Y.Z. The launcher at that tag defaults to node image # cryptoandcoffee/k3d-gpu:X.Y.Z, so after tagging this dispatches build-and-push.yml # with release=X.Y.Z; that run publishes the image and then dispatches aur-deploy.yml. # Dispatches bypass the GITHUB_TOKEN downstream-trigger limit. # +# A dispatch's mode: force (the default) always releases; changed releases only +# when a commit above the last tag changed a shipped path (release_cas.sh runs as +# for a push), so a merge that a GITHUB_TOKEN push did not announce can still cut +# its release, and a dispatch with nothing shipped cuts none. +# # hack/release_cas.sh releases from a fresh origin/main and pushes main and the # tag in one atomic push (--atomic), retrying when another writer moved main or # took the tag. On those lock and non-fast-forward races GitHub rejected both @@ -14,9 +23,9 @@ name: Auto-release AUR # other rejection classes a release commit can land # without its tag, and the next run tags it in place. A push run whose commits # an earlier run already released exits with no tag. The -# concurrency group only queues runs; correctness does not depend on it. Push and -# dispatch runs queue separately, so a pending forced dispatch is never replaced -# by a push run that finds nothing to release. +# concurrency group only queues runs; correctness does not depend on it. Push +# runs and each dispatch mode queue separately, so a pending forced dispatch is +# never replaced by a run that finds nothing to release. on: push: @@ -26,7 +35,28 @@ on: - 'scripts/**' - 'share/**' - 'Dockerfile' + - 'console/**' + - 'images.lock' + - 'bench/vs.py' + - 'bench/llm_load.py' + - 'bench/energy.sh' + - 'bench/agent_trace.py' + - 'bench/agent_trace_gen.py' + - 'bench/apb/apb.py' + - 'bench/apb/capacity.py' + - 'bench/competitors.lock' + - 'oracle/bundle.py' + - 'oracle/project.py' + - 'oracle/validate.py' + - 'oracle/share.py' + - 'oracle/schema/bundle-v1.json' workflow_dispatch: + inputs: + mode: + description: 'force: always release; changed: release only when a shipped path changed since the last tag' + type: choice + options: [force, changed] + default: force permissions: contents: write @@ -36,8 +66,9 @@ jobs: release: if: "github.actor != '88plug-bot' && !contains(github.event.head_commit.message, '[skip release]')" runs-on: ubuntu-latest + timeout-minutes: 15 concurrency: - group: auto-release-${{ github.event_name }} + group: auto-release-${{ github.event_name }}-${{ inputs.mode || 'push' }} cancel-in-progress: false steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -48,7 +79,7 @@ jobs: - name: Release (atomic push of main and the tag) id: rel env: - EVENT: ${{ github.event_name }} + EVENT: ${{ github.event_name == 'workflow_dispatch' && inputs.mode == 'changed' && 'push' || github.event_name }} run: | git config user.name "88plug-bot" git config user.email "andrew@88plug.com" diff --git a/.github/workflows/bot-sweep.yml b/.github/workflows/bot-sweep.yml new file mode 100644 index 00000000..e19ef05e --- /dev/null +++ b/.github/workflows/bot-sweep.yml @@ -0,0 +1,41 @@ +name: Bot sweep + +# Hourly upkeep of the bot's PRs (branches autobump/*, opened by github-actions[bot] +# with GITHUB_TOKEN, so their own pull_request runs wait for approval). +# +# ci-fill: hack/bot-ci-dispatch runs ci.yml by workflow_dispatch on each bot PR +# branch with no run at its head, and re-runs a first-attempt failure +# once, one bot run in flight at a time and none while a human PR's CI +# is queued. It fills in what the nightly deferred. + +on: + schedule: + - cron: '13 * * * *' + workflow_dispatch: + +permissions: {} + +concurrency: + group: bot-sweep + cancel-in-progress: false + +jobs: + ci-fill: + name: CI on bot PRs + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + actions: write + contents: read + pull-requests: read + steps: + - name: Checkout main + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main + persist-credentials: false + + - name: Dispatch CI on bot PRs that have none at their head + env: + GH_TOKEN: ${{ github.token }} + run: hack/bot-ci-dispatch diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml index 46c75bd8..621cc7b0 100644 --- a/.github/workflows/build-and-push.yml +++ b/.github/workflows/build-and-push.yml @@ -5,19 +5,23 @@ name: Auto-update, Build & Release # check (nightly, or manual without `release`): resolve upstream versions; when # one moved (or `force`), commit the bumped pins to main and dispatch # auto-release.yml, which cuts a launcher release for them. -# release (dispatched by auto-release.yml with `release=X.Y.Z`): build the image -# from tag aur-vX.Y.Z, publish :X.Y.Z (the launcher's default -# image) next to the floating tags, then dispatch aur-deploy.yml and -# packages.yml (deb, rpm, Homebrew) so the launcher reaches every package -# channel only once its image exists. +# release (dispatched by auto-release.yml with `release=X.Y.Z`, or by +# hack/watchdog when a release tag has no image): build the image from +# tag aur-vX.Y.Z, publish :X.Y.Z (the launcher's default image), +# move the floating tags when X.Y.Z is the newest release, then dispatch +# aur-deploy.yml and packages.yml (deb, rpm, Homebrew) so the launcher +# reaches every package channel only once its image exists. # # Every image is built from a release tag, so every published image has a # launcher version. Pushes made with GITHUB_TOKEN start no workflow runs, but # workflow_dispatch does, hence the explicit dispatches. +# +# Each release version queues on its own (one release never replaces another +# pending release); the nightly check queues in `nightly`. on: schedule: - - cron: '0 6 * * *' + - cron: '17 6 * * *' workflow_dispatch: inputs: force: @@ -30,7 +34,7 @@ on: default: '' concurrency: - group: build-and-push + group: build-and-push-${{ inputs.release && format('release-{0}', inputs.release) || 'nightly' }} cancel-in-progress: false permissions: @@ -42,6 +46,7 @@ jobs: check: if: ${{ !inputs.release }} runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Checkout main uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -292,6 +297,7 @@ jobs: release: if: ${{ inputs.release }} runs-on: ubuntu-latest + timeout-minutes: 60 env: RELEASE: ${{ inputs.release }} steps: @@ -347,9 +353,76 @@ jobs: exit 1 fi + # hack/release-tags comes from main: the release tag checked out above can + # predate it (the watchdog re-dispatches an old release whose image is missing). + - name: Checkout main's release tooling + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main + path: .ci-main + sparse-checkout: hack + persist-credentials: false + + # The builds push the versioned tags (:X.Y.Z, -ubuntu26.04, -ubuntu24.04) + # only; the floating tags move after both builds, below. + - name: Tags to push + id: tags + env: + K3S: ${{ steps.current.outputs.k3s }} + RELEASE_REPOS: cryptoandcoffee/k3d-gpu ghcr.io/${{ github.repository_owner }}/k3d-gpu + run: | + tags=$(.ci-main/hack/release-tags "$RELEASE" "$K3S") + printf '%s\n' "$tags" + awk '$2 == "versioned" { print $1 }' <<< "$tags" | sort -u | while read -r v; do + echo "v${v/./}<> "$GITHUB_OUTPUT" + # Ubuntu 26.04 is the default (:latest and :X.Y.Z); 24.04 is also published. # Each carries the baked device plugins, so clusters expose GPUs with no apply. - - name: Build & push — Ubuntu 26.04 (default / latest) + # Each variant builds in up to 3 attempts with the same inputs (a Docker Hub + # 429 stopped release 0.1.76 with its tag cut and no image): attempts 1 and 2 + # continue on error and are followed by a 2 and a 4 min wait; attempt 3 fails + # the job. + - name: Build & push — Ubuntu 26.04 (attempt 1) + id: b2604_1 + continue-on-error: true + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + push: true + platforms: linux/amd64 + labels: | + org.opencontainers.image.version=${{ inputs.release }} + org.opencontainers.image.revision=aur-v${{ inputs.release }} + tags: ${{ steps.tags.outputs.v2604 }} + + - name: Wait 2 min before Ubuntu 26.04 attempt 2 + if: steps.b2604_1.outcome == 'failure' + run: sleep 120 + + - name: Build & push — Ubuntu 26.04 (attempt 2) + id: b2604_2 + if: steps.b2604_1.outcome == 'failure' + continue-on-error: true + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + push: true + platforms: linux/amd64 + labels: | + org.opencontainers.image.version=${{ inputs.release }} + org.opencontainers.image.revision=aur-v${{ inputs.release }} + tags: ${{ steps.tags.outputs.v2604 }} + + - name: Wait 4 min before Ubuntu 26.04 attempt 3 + if: steps.b2604_2.outcome == 'failure' + run: sleep 240 + + - name: Build & push — Ubuntu 26.04 (attempt 3) + id: b2604_3 + if: steps.b2604_2.outcome == 'failure' uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . @@ -358,19 +431,11 @@ jobs: labels: | org.opencontainers.image.version=${{ inputs.release }} org.opencontainers.image.revision=aur-v${{ inputs.release }} - tags: | - cryptoandcoffee/k3d-gpu:${{ inputs.release }} - cryptoandcoffee/k3d-gpu:${{ inputs.release }}-ubuntu26.04 - cryptoandcoffee/k3d-gpu:latest - cryptoandcoffee/k3d-gpu:ubuntu26.04 - cryptoandcoffee/k3d-gpu:${{ steps.current.outputs.k3s }}-ubuntu26.04 - ghcr.io/${{ github.repository_owner }}/k3d-gpu:${{ inputs.release }} - ghcr.io/${{ github.repository_owner }}/k3d-gpu:${{ inputs.release }}-ubuntu26.04 - ghcr.io/${{ github.repository_owner }}/k3d-gpu:latest - ghcr.io/${{ github.repository_owner }}/k3d-gpu:ubuntu26.04 - ghcr.io/${{ github.repository_owner }}/k3d-gpu:${{ steps.current.outputs.k3s }}-ubuntu26.04 - - - name: Build & push — Ubuntu 24.04 (variant) + tags: ${{ steps.tags.outputs.v2604 }} + + - name: Build & push — Ubuntu 24.04 (attempt 1) + id: b2404_1 + continue-on-error: true uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . @@ -381,13 +446,65 @@ jobs: labels: | org.opencontainers.image.version=${{ inputs.release }} org.opencontainers.image.revision=aur-v${{ inputs.release }} - tags: | - cryptoandcoffee/k3d-gpu:${{ inputs.release }}-ubuntu24.04 - cryptoandcoffee/k3d-gpu:ubuntu24.04 - cryptoandcoffee/k3d-gpu:${{ steps.current.outputs.k3s }}-ubuntu24.04 - ghcr.io/${{ github.repository_owner }}/k3d-gpu:${{ inputs.release }}-ubuntu24.04 - ghcr.io/${{ github.repository_owner }}/k3d-gpu:ubuntu24.04 - ghcr.io/${{ github.repository_owner }}/k3d-gpu:${{ steps.current.outputs.k3s }}-ubuntu24.04 + tags: ${{ steps.tags.outputs.v2404 }} + + - name: Wait 2 min before Ubuntu 24.04 attempt 2 + if: steps.b2404_1.outcome == 'failure' + run: sleep 120 + + - name: Build & push — Ubuntu 24.04 (attempt 2) + id: b2404_2 + if: steps.b2404_1.outcome == 'failure' + continue-on-error: true + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + push: true + platforms: linux/amd64 + build-args: | + UBUNTU_IMAGE=${{ steps.current.outputs.ubuntu2404 }} + labels: | + org.opencontainers.image.version=${{ inputs.release }} + org.opencontainers.image.revision=aur-v${{ inputs.release }} + tags: ${{ steps.tags.outputs.v2404 }} + + - name: Wait 4 min before Ubuntu 24.04 attempt 3 + if: steps.b2404_2.outcome == 'failure' + run: sleep 240 + + - name: Build & push — Ubuntu 24.04 (attempt 3) + id: b2404_3 + if: steps.b2404_2.outcome == 'failure' + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + push: true + platforms: linux/amd64 + build-args: | + UBUNTU_IMAGE=${{ steps.current.outputs.ubuntu2404 }} + labels: | + org.opencontainers.image.version=${{ inputs.release }} + org.opencontainers.image.revision=aur-v${{ inputs.release }} + tags: ${{ steps.tags.outputs.v2404 }} + + # The floating tags (latest, ubuntu26.04, ubuntu24.04, -ubuntu26.04, + # -ubuntu24.04) move only when this release is the highest aur-v* tag on + # origin now, after both variants pushed: releases build in parallel (one + # queue per version), so an older one that finishes late leaves them alone. + # Each is a registry-side copy of the versioned tag pushed above. + - name: Move the floating tags (newest release only) + env: + K3S: ${{ steps.current.outputs.k3s }} + RELEASE_REPOS: cryptoandcoffee/k3d-gpu ghcr.io/${{ github.repository_owner }}/k3d-gpu + run: | + tags=$(.ci-main/hack/release-tags "$RELEASE" "$K3S") + n=0 + while read -r _ kind ref src; do + [ "$kind" = floating ] || continue + docker buildx imagetools create --tag "$ref" "$src" + n=$((n + 1)) + done <<< "$tags" + echo "moved $n floating tags" - name: Create GitHub Release uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 @@ -440,11 +557,15 @@ jobs: # component on branch autobump/. Merging it changes share/** on main, # which auto-release.yml turns into a launcher release, and that release # dispatches the `release` job above to build the image. A PR opened with - # GITHUB_TOKEN starts no CI workflows, so the tests run here before it opens. - # A new component is a new hack/components// dir; the matrix picks it up. + # GITHUB_TOKEN gets pull_request CI runs that wait for a maintainer's approval, + # so the tests run here before it opens, and hack/bot-ci-dispatch then runs + # ci.yml on the branch by workflow_dispatch (no approval): that run is the PR's + # CI evidence. A new component is a new hack/components// dir; the matrix + # picks it up. list-components: if: ${{ !inputs.release }} runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: read outputs: @@ -463,9 +584,11 @@ jobs: needs: list-components if: needs.list-components.outputs.names != '[]' runs-on: ubuntu-latest + timeout-minutes: 30 permissions: contents: write pull-requests: write + actions: write strategy: fail-fast: false matrix: @@ -503,6 +626,12 @@ jobs: - name: Render tests run: tests/render_test.sh + # create-pull-request force-pushes an existing bump branch, which GitHub + # refuses from GITHUB_TOKEN once main's workflow files differ from the + # branch's: delete such a branch first (its PR closes; a fresh one opens). + - name: Delete a bump branch whose workflows differ from main's + run: hack/autobump-branch-reset "autobump/${COMPONENT}" + - name: Open or update the bump PR uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: @@ -522,27 +651,39 @@ jobs: - Pins (version, chart sha256, image digests) moved in `hack/components/${{ matrix.component }}/component.conf`. - `tests/detect_test.sh` and `tests/render_test.sh` passed in the workflow run that opened this PR. + - CI: this PR's own pull_request runs wait for approval (a GITHUB_TOKEN PR). + The evidence is the ci.yml run dispatched on `autobump/${{ matrix.component }}` + at the head SHA: `gh run list -w ci.yml -e workflow_dispatch -b autobump/${{ matrix.component }}`. - Merging rebuilds the node image with the new manifest baked in. Review the manifest diff for new CRDs, RBAC, images and flags before merging. + - name: Dispatch CI on the bump PR's branch + env: + GH_TOKEN: ${{ github.token }} + run: hack/bot-ci-dispatch --branch "autobump/${COMPONENT}" + # ─── Image digests ─────────────────────────────────────────────────────── # Every image reference is pinned name:tag@sha256 (scripts/images-lock pins). # A registry can re-point a tag (a rebuilt base image, a security respin) with # no new tag, which the version checks above never see. Daily: re-resolve the # tag of every pinned reference, write the registry's digest where it moved, # re-pin images.lock, run the image tests, and open or update one PR on branch - # autobump/image-digests. A PR opened with GITHUB_TOKEN starts no CI workflows, - # so the tests run here before it opens. GITHUB_TOKEN cannot push a change to - # .github/workflows/ (no `workflows` permission), so repin leaves those files - # alone; a workflow reads its image pins from a file outside them (the - # Dockerfile, docs-build/Dockerfile, hack/ci-images.env), which repin moves. + # autobump/image-digests. A PR opened with GITHUB_TOKEN gets pull_request CI + # runs that wait for a maintainer's approval, so the tests run here before it + # opens, and hack/bot-ci-dispatch then runs ci.yml on the branch by + # workflow_dispatch: that run is the PR's CI evidence. GITHUB_TOKEN cannot push + # a change to .github/workflows/ (no `workflows` permission), so repin leaves + # those files alone; a workflow reads its image pins from a file outside them + # (the Dockerfile, docs-build/Dockerfile, hack/ci-images.env), which repin moves. repin-images: if: ${{ !inputs.release }} runs-on: ubuntu-latest + timeout-minutes: 30 permissions: contents: write pull-requests: write + actions: write steps: - name: Checkout main uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -594,12 +735,7 @@ jobs: # its open PR; the action then cuts a fresh branch off main and opens a new one. - name: Delete a digest branch whose workflows differ from main's if: steps.repin.outputs.changes != '' - run: | - if git fetch -q --depth=1 origin autobump/image-digests 2>/dev/null \ - && ! git diff --quiet FETCH_HEAD HEAD -- .github/workflows/; then - echo "autobump/image-digests was cut from a main with other workflow files; deleting it" - git push origin --delete autobump/image-digests - fi + run: hack/autobump-branch-reset autobump/image-digests - name: Open or update the digest PR if: steps.repin.outputs.changes != '' @@ -622,32 +758,47 @@ jobs: - `tests/image_pins_test.sh`, `tests/audit_test.sh`, `tests/detect_test.sh`, `tests/render_test.sh` and the render check passed in the workflow run that opened this PR. + - CI: this PR's own pull_request runs wait for approval (a GITHUB_TOKEN PR). + The evidence is the ci.yml run dispatched on `autobump/image-digests` at the + head SHA: `gh run list -w ci.yml -e workflow_dispatch -b autobump/image-digests`. Review what the upstream rebuild changed before merging. A change under `share/`, `scripts/` or the Dockerfile cuts a launcher release on merge. + - name: Dispatch CI on the digest PR's branch + if: steps.repin.outputs.changes != '' + env: + GH_TOKEN: ${{ github.token }} + run: hack/bot-ci-dispatch --branch autobump/image-digests + # ─── QAT conversion toolchain (console/convert-pins.json) ────────────────── # The model optimizer's one-time OpenVINO conversion pins its pip toolchain # exactly. Daily: move each pin to the newest release optimum-intel's own # requirements allow (hack/bump-convert-pins), run the console tests, and open - # or update one PR on branch autobump/convert-pins. + # or update one PR on branch autobump/convert-pins, whose CI evidence is the + # ci.yml run hack/bot-ci-dispatch runs on the branch. bump-convert-pins: if: ${{ !inputs.release }} runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: write pull-requests: write + actions: write steps: - name: Checkout main uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: main + - name: Tool versions (hack/ci-tools.env) + run: grep -E '^[A-Z0-9_]+=' hack/ci-tools.env >> "$GITHUB_ENV" + - name: Move the pins to the newest compatible releases id: bump run: | python3 -m venv "$RUNNER_TEMP/venv" - "$RUNNER_TEMP/venv/bin/pip" install --disable-pip-version-check packaging==26.3 + "$RUNNER_TEMP/venv/bin/pip" install --disable-pip-version-check "packaging==${PACKAGING_VERSION}" "$RUNNER_TEMP/venv/bin/python" hack/bump-convert-pins | tee "$RUNNER_TEMP/bump.txt" # The moved set must resolve together: pip's resolver, no install. mapfile -t pins < <(jq -r '.pins | to_entries[] | "\(.key)==\(.value)"' console/convert-pins.json) @@ -663,6 +814,12 @@ jobs: tests/console_test.sh tests/model_opt_test.sh + # As in repin-images: GitHub refuses create-pull-request's force-push from + # GITHUB_TOKEN once main's workflow files differ from the branch's. + - name: Delete a pins branch whose workflows differ from main's + if: steps.bump.outputs.changes != '' + run: hack/autobump-branch-reset autobump/convert-pins + - name: Open or update the pins PR if: steps.bump.outputs.changes != '' uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 @@ -682,3 +839,13 @@ jobs: `tests/console_test.sh` and `tests/model_opt_test.sh` passed in the workflow run that opened this PR. + + CI: this PR's own pull_request runs wait for approval (a GITHUB_TOKEN PR). + The evidence is the ci.yml run dispatched on `autobump/convert-pins` at the + head SHA: `gh run list -w ci.yml -e workflow_dispatch -b autobump/convert-pins`. + + - name: Dispatch CI on the pins PR's branch + if: steps.bump.outputs.changes != '' + env: + GH_TOKEN: ${{ github.token }} + run: hack/bot-ci-dispatch --branch autobump/convert-pins diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml new file mode 100644 index 00000000..8be93728 --- /dev/null +++ b/.github/workflows/canary.yml @@ -0,0 +1,237 @@ +name: Canary + +# Every engine or node-image bump, from a bot or a human, checked at runtime before it +# merges (canary/README.md): +# runtime vllm-cpu, ovms-cpu, llama-cpu (and eat-vllm-cpu, informational): pull the +# pinned image, serve a pinned 0.6B model the way the launcher would, one real +# and one streamed completion, the version the server reports; +# surface every engine image: the flags and env names the launcher emits exist in it, +# and the constants k3d-gpu derives from it still match (no device needed); +# node build the node image, start a k3d cluster on it, everything baked comes up. +# A pull request or `engines=auto` runs the engines whose pin, flags, settings or golden +# differ from merge-base(origin/main, HEAD), or from the pull request's base branch +# (canary/plan.py matrix); the schedule runs all. +# The report job writes canary-result.json (artifact canary-result) and is green only when +# every selected engine's verdict is pass with hard true. +# +# Read-only: no secrets, no registry login (public images), GitHub-hosted runners only. +# The re-dispatch job alone may write: it starts this workflow again (attempt + 1, at most +# 3) when an engine was isa-skip, the runner's CPU lacking an instruction set it needs. + +on: + schedule: + - cron: '29 3 * * *' + workflow_dispatch: + inputs: + engines: + description: 'engine ids from canary/engines.toml, comma separated, or auto (the engines that differ from main)' + type: string + default: auto + attempt: + description: 'attempt number: an isa-skip re-dispatches with attempt + 1, at most 3' + type: number + default: 1 + pull_request: + paths: + - Dockerfile + - scripts/k3d-gpu + - 'share/**' + - console/moe_predict.py + - console/plan.py + - console/model_opt.py + - console/fit.py + - bench/h54/h54.py # sglang_args, the sglang flags the surface check reads + - 'canary/**' + - packaging/install.sh + - integrations/eat/vllm-values.yaml + - .github/workflows/canary.yml + +permissions: + contents: read + +concurrency: + group: canary-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + plan: + name: Plan + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + outputs: + runtime: ${{ steps.plan.outputs.runtime }} + surface: ${{ steps.plan.outputs.surface }} + node: ${{ steps.plan.outputs.node }} + selected: ${{ steps.plan.outputs.selected }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # merge-base(origin/main, HEAD) + persist-credentials: false + # auto: against merge-base(origin/, HEAD) + - id: plan + env: + EVENT: ${{ github.event_name }} + ENGINES: ${{ inputs.engines }} + BASE: ${{ github.base_ref || 'main' }} + run: | + mode=auto + [ "$EVENT" != schedule ] || mode=all + python3 canary/plan.py matrix --mode "$mode" --engines "${ENGINES:-auto}" --base "origin/$BASE" \ + --github-output "$GITHUB_OUTPUT" + + runtime: + name: Runtime (${{ matrix.id }}) + needs: plan + if: needs.plan.outputs.runtime != '[]' + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.plan.outputs.runtime) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # The model at its pinned revision; canary/run.sh downloads it on a miss. + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ matrix.cache_path }} + key: ${{ matrix.cache_key }} + - name: canary/run.sh + env: + ID: ${{ matrix.id }} + CANARY_FREE_DISK: '1' + CANARY_OUT_DIR: ${{ runner.temp }}/canary-out + run: canary/run.sh "$ID" + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: always() + with: + name: canary-part-${{ matrix.id }} + path: ${{ runner.temp }}/canary-out/ + if-no-files-found: ignore + retention-days: 14 + + surface: + name: Surface (${{ matrix.id }}) + needs: plan + if: needs.plan.outputs.surface != '[]' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + strategy: + fail-fast: false + max-parallel: 4 + matrix: + include: ${{ fromJSON(needs.plan.outputs.surface) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: canary/run.sh --surface + env: + ID: ${{ matrix.id }} + CANARY_FREE_DISK: '1' + CANARY_OUT_DIR: ${{ runner.temp }}/canary-out + run: canary/run.sh --surface "$ID" + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: always() + with: + name: canary-part-${{ matrix.id }} + path: ${{ runner.temp }}/canary-out/ + if-no-files-found: ignore + retention-days: 14 + + node: + name: Node image + needs: plan + if: needs.plan.outputs.node == 'true' + runs-on: ubuntu-latest + timeout-minutes: 40 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: canary/node.sh + env: + CANARY_OUT_DIR: ${{ runner.temp }}/canary-out + run: canary/node.sh + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: always() + with: + name: canary-part-node + path: ${{ runner.temp }}/canary-out/ + if-no-files-found: ignore + retention-days: 14 + + report: + name: Report + needs: [plan, runtime, surface, node] + if: always() && needs.plan.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + outputs: + isa_skip: ${{ steps.report.outputs.isa_skip }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + pattern: canary-part-* + merge-multiple: true + path: ${{ runner.temp }}/parts + - id: report + env: + SELECTED: ${{ needs.plan.outputs.selected }} + SHA: ${{ github.event.pull_request.head.sha || github.sha }} + REF: ${{ github.head_ref || github.ref_name }} + run: | + mkdir -p "$RUNNER_TEMP/parts" + rc=0 + python3 canary/plan.py report --selected "$SELECTED" --parts "$RUNNER_TEMP/parts" --sha "$SHA" --ref "$REF" \ + --out canary-result.json --summary "$GITHUB_STEP_SUMMARY" --github-output "$GITHUB_OUTPUT" || rc=$? + echo "rc=$rc" >> "$GITHUB_OUTPUT" + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: canary-result + path: canary-result.json + retention-days: 30 + - name: Green only when every selected engine passed + env: + RC: ${{ steps.report.outputs.rc }} + run: exit "${RC:-1}" + + redispatch: + name: Re-dispatch isa-skip engines + needs: report + if: >- + always() && needs.report.outputs.isa_skip != '' && + (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + actions: write + steps: + - env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + REF: ${{ github.head_ref || github.ref_name }} + ENGINES: ${{ needs.report.outputs.isa_skip }} + ATTEMPT: ${{ inputs.attempt || 1 }} + run: | + if [ "$ATTEMPT" -ge 3 ]; then + echo "::warning::$ENGINES isa-skip on attempt $ATTEMPT of 3: no runner with the instruction set it needs; the canary stays not green" + exit 0 + fi + gh workflow run canary.yml --repo "$REPO" --ref "$REF" -f engines="$ENGINES" -f attempt="$((ATTEMPT + 1))" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81cd1259..06a65611 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,11 +5,16 @@ on: branches: [main] pull_request: branches: [main] + # hack/watchdog dispatches it on main when main's HEAD has no CI run (a push made + # with GITHUB_TOKEN starts no workflow); it tests the dispatched ref's head. + # hack/bot-ci-dispatch runs CI on a bot PR's branch this way: the PR's own + # pull_request runs wait for approval (GITHUB_TOKEN opened it), a dispatch does not. + workflow_dispatch: permissions: contents: read -# A new push to a PR supersedes its older run; main runs always finish. +# A new push to a PR supersedes its older run; main and dispatched runs always finish. concurrency: group: ci-${{ github.event.pull_request.number || github.sha }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} @@ -18,17 +23,21 @@ jobs: shellcheck: name: Shellcheck runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Tool versions (hack/ci-tools.env) + run: grep -E '^[A-Z0-9_]+=' hack/ci-tools.env >> "$GITHUB_ENV" - uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0 with: scandir: . severity: warning - version: v0.11.0 + version: ${{ env.SHELLCHECK_VERSION }} actionlint: name: Workflow lint runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # rhysd/actionlint, pinned by digest in hack/ci-images.env (the nightly repin moves it). @@ -40,6 +49,7 @@ jobs: detect-tests: name: Detection tests runs-on: ubuntu-latest + timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - run: tests/detect_test.sh @@ -55,11 +65,13 @@ jobs: - run: tests/prefill_guard_test.sh - run: tests/ci_wiring_test.sh - run: tests/gate_merge_test.sh + - run: tests/bot_ci_dispatch_test.sh - run: tests/auto_release_cas_test.sh quickstart-tests: name: Quickstart, uninstall and installer runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - run: tests/quickstart_test.sh @@ -68,6 +80,7 @@ jobs: fuzz-props: name: Launcher input properties (fuzz) runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # A fresh seed each run; a failure prints its FUZZ_SEED replay line. @@ -76,6 +89,7 @@ jobs: remote-tests: name: Remote OpenAI endpoints (resolver, Secret, sandbox egress, redaction) runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - run: python3 tests/remote_test.py @@ -84,13 +98,11 @@ jobs: telemetry-tests: name: Telemetry manifests runs-on: ubuntu-latest - env: - PROMETHEUS_VERSION: 3.15.1 - PROMETHEUS_SHA256: 6a726885328ad9efebbf36e876ce2fb6225468a71fd1e7ce3ba666d5f4d97a84 - # ghcr.io/yannh/kubeconform:v0.8.0 - KUBECONFORM_IMAGE: ghcr.io/yannh/kubeconform@sha256:faffaf43f95aa6425306e1ab8d6fcad72acb9049158f38e574c085ea1ec0f64e + timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Tool versions (hack/ci-tools.env) + run: grep -E '^[A-Z0-9_]+=' hack/ci-tools.env >> "$GITHUB_ENV" - name: Install promtool run: | tgz="prometheus-${PROMETHEUS_VERSION}.linux-amd64.tar.gz" @@ -108,6 +120,7 @@ jobs: agent-manifests: name: Agent manifests (kubeconform) runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - run: tests/agent_kubeconform.sh @@ -115,6 +128,7 @@ jobs: intel-gpu-dra-manifests: name: Intel GPU DRA manifests (kubeconform) runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - run: tests/kubeconform.sh @@ -122,6 +136,7 @@ jobs: share-manifests: name: share/ manifests (kubeconform, CRD schemas) runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - run: sudo apt-get update && sudo apt-get install -y --no-install-recommends python3-yaml @@ -130,10 +145,13 @@ jobs: console-tests: name: Console spine (facts/v1, plan, audit) runs-on: ubuntu-latest + timeout-minutes: 40 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Tool versions (hack/ci-tools.env) + run: grep -E '^[A-Z0-9_]+=' hack/ci-tools.env >> "$GITHUB_ENV" - name: Install jsonschema for the schema cross-check (pinned) - run: python3 -m venv /tmp/jsonschema && /tmp/jsonschema/bin/pip install --disable-pip-version-check jsonschema==4.26.0 + run: python3 -m venv /tmp/jsonschema && /tmp/jsonschema/bin/pip install --disable-pip-version-check "jsonschema==${JSONSCHEMA_VERSION}" - env: CONSOLE_JSONSCHEMA_PYTHON: /tmp/jsonschema/bin/python run: tests/console_test.sh @@ -152,6 +170,7 @@ jobs: images-lock: name: images.lock complete, every image pinned by digest runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - run: scripts/images-lock check @@ -160,12 +179,15 @@ jobs: report-html: name: report.html (goldens, offline load in headless Chrome) runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Tool versions (hack/ci-tools.env) + run: grep -E '^[A-Z0-9_]+=' hack/ci-tools.env >> "$GITHUB_ENV" - id: chrome uses: browser-actions/setup-chrome@48ad923757ca74d66703209fe939badbdf80f2f4 # v2.2.0 with: - chrome-version: 156.0.8078.73 + chrome-version: ${{ env.CHROME_VERSION }} # The headless load runs Chrome in a network namespace (unshare -rn); # Ubuntu's AppArmor blocks unprivileged user namespaces by default. - run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 @@ -177,6 +199,7 @@ jobs: oracle: name: Capacity oracle (schema, privacy, validator, fuzz, matrix, leaderboard, share) runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -200,6 +223,7 @@ jobs: claims-check: name: Docs claims guard runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - run: tests/claims_check_test.sh @@ -213,6 +237,7 @@ jobs: bench-tests: name: Bench harness tests runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - run: tests/bench_test.sh @@ -222,6 +247,7 @@ jobs: fit-tests: name: k3d-gpu fit and guard tests runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # tests/fit_test.py (the fit arithmetic on fixture hosts and models, the guard @@ -234,6 +260,7 @@ jobs: tune-tests: name: k3d-gpu tune tests runs-on: ubuntu-latest + timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # TUNE_E2E_FULL: two whole tunes against tests/fake_ovms.py, one where no @@ -245,6 +272,7 @@ jobs: capacity-tests: name: k3d-gpu capacity tests (stub server) runs-on: ubuntu-latest + timeout-minutes: 25 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - run: python3 bench/apb/test_capacity.py @@ -269,6 +297,9 @@ jobs: - run: python3 bench/h54gpu/test_h54gpu.py - run: python3 bench/h57d/test_h57d.py - run: python3 bench/h59/test_h59.py + # The H59-family runner (amendment R1): renders through the launcher against the fit + # fixtures, arm.sh against a local stub server, its records scored by h59.py. + - run: python3 bench/h59/test_run.py - run: python3 bench/h62/test_h62.py - run: python3 bench/admission/test_ctl.py - run: python3 bench/admission/test_analyze.py @@ -277,6 +308,7 @@ jobs: mcp-tests: name: MCP server tests runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - run: tests/mcp_test.sh @@ -284,6 +316,7 @@ jobs: kuberay-example: name: KubeRay example (static) runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - run: sudo apt-get update && sudo apt-get install -y python3-yaml @@ -292,11 +325,11 @@ jobs: chat-ui-manifest: name: Chat UI manifest (static + kubeconform) runs-on: ubuntu-latest - env: - KUBECONFORM_VERSION: v0.8.0 - KUBECONFORM_SHA256: 9bc2bffbf71f261128533edaf912153948b7ff238f9a531ae6d34466ec287883 + timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Tool versions (hack/ci-tools.env) + run: grep -E '^[A-Z0-9_]+=' hack/ci-tools.env >> "$GITHUB_ENV" - name: Install kubeconform run: | curl -fsSL -o kubeconform.tar.gz \ @@ -311,6 +344,7 @@ jobs: sim-fixtures: name: Simulated hardware list runs-on: ubuntu-latest + timeout-minutes: 10 outputs: platforms: ${{ steps.list.outputs.platforms }} steps: @@ -322,6 +356,7 @@ jobs: name: Simulated hardware (${{ matrix.platform }}) needs: sim-fixtures runs-on: ubuntu-latest + timeout-minutes: 10 strategy: fail-fast: false matrix: @@ -335,6 +370,7 @@ jobs: agent-examples: name: Agent framework examples (${{ matrix.example }}) runs-on: ubuntu-latest + timeout-minutes: 10 strategy: fail-fast: false matrix: @@ -346,6 +382,7 @@ jobs: register-model-test: name: LiteLLM register-model.sh runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - run: tests/register_model_test.sh @@ -353,6 +390,7 @@ jobs: render: name: Rendered components runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install pinned helm @@ -371,6 +409,7 @@ jobs: packaging-tests: name: Packaging consistency tests runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - run: tests/packaging_test.sh @@ -378,6 +417,7 @@ jobs: deb-rpm: name: Build and test .deb + .rpm (nfpm) runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - run: packaging/check.sh nfpm @@ -385,6 +425,7 @@ jobs: homebrew: name: Homebrew formula style, audit, install, test runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - run: packaging/check.sh brew @@ -392,6 +433,7 @@ jobs: nix-flake: name: Nix flake check runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - run: packaging/check.sh nix @@ -399,6 +441,7 @@ jobs: hadolint: name: Dockerfile lint runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0 @@ -411,6 +454,7 @@ jobs: arch-image: name: Arch image pin runs-on: ubuntu-latest + timeout-minutes: 10 outputs: ref: ${{ steps.pin.outputs.ref }} steps: @@ -428,6 +472,7 @@ jobs: name: Lint PKGBUILD in Arch container needs: arch-image runs-on: ubuntu-latest + timeout-minutes: 10 container: ${{ needs.arch-image.outputs.ref }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -448,14 +493,14 @@ jobs: kserve-example: name: KServe example (stub install + kubeconform 1.34/1.35) runs-on: ubuntu-latest - env: - KUBECONFORM_VERSION: v0.8.0 - KUBECONFORM_SHA256: 9bc2bffbf71f261128533edaf912153948b7ff238f9a531ae6d34466ec287883 + timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Tool versions (hack/ci-tools.env) + run: grep -E '^[A-Z0-9_]+=' hack/ci-tools.env >> "$GITHUB_ENV" - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 with: - version: v4.3.1 + version: ${{ env.HELM4_VERSION }} - name: Install kubeconform and PyYAML run: | curl -fsSL -o kubeconform.tar.gz \ @@ -465,6 +510,19 @@ jobs: sudo apt-get update && sudo apt-get install -y --no-install-recommends python3-yaml - run: tests/kserve_test.sh + # ─── Alarm, watchdog and release hardening ────────────────────────────── + ops-tests: + name: Alarm, watchdog and release tags + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: tests/ci_alarm_test.sh + - run: tests/watchdog_test.sh + - run: tests/release_tags_test.sh + # --- Freshness: known issues --------------------------------------------------- # pins/known-issues.toml lists the upstream bugs, behaviour changes and advisories # k3d-gpu depends on, each with the release that fixes it. Offline: fails when an @@ -484,3 +542,110 @@ jobs: - run: python3 tests/known_issues_test.py - run: python3 tests/ledger_freshness_test.py - run: hack/known-issues check + + # Part canary (.github/workflows/canary.yml runs the engines themselves): canary/run.sh + # against stub engines and a docker shim, canary/node.sh against scripted pod states, the + # surface parser on vLLM v0.32.0's real sources and the pinned images' help output, + # flags.sh, the plan and the report. No image is pulled. + canary-tests: + name: Canary harness (stub engines, surface parser, plan, report) + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: tests/canary_test.sh + - run: tests/canary_node_test.sh + - run: tests/canary_spec_test.sh + - run: python3 tests/canary_surface_test.py + + # ---- Pin classes (pins/classes) ---- + # A finished run's record is never edited; a PENDING ledger row's pin moves only + # with a dated amendment in the same diff; an autobump branch touches neither + # .github/ nor the ledger (hack/pin-guard). Every pin-bearing file under bench/ and + # oracle/ is classified (hack/pin-classes coverage). A pull request is checked from + # the commit it forked at to its head (HEAD^2 of the merge ref). + pin-guard: + name: Pin classes (ledger and historical pins) + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - run: python3 tests/pin_classes_test.py + - run: tests/pin_guard_test.sh + - name: hack/pin-classes coverage + run: hack/pin-classes coverage + - name: hack/pin-guard + env: + EVENT: ${{ github.event_name }} + BASE_REF: ${{ github.base_ref }} + BRANCH: ${{ github.head_ref || github.ref_name }} + run: | + case "${EVENT}" in + pull_request) base="origin/${BASE_REF}" head=HEAD^2 ;; + push) base=HEAD^1 head=HEAD ;; + *) base=origin/main head=HEAD ;; + esac + args=(--base "${base}" --head "${head}" --branch "${BRANCH}") + if [[ "${BRANCH}" == autobump/* ]]; then args+=(--autobump); fi + hack/pin-guard "${args[@]}" + + # ─── Bot PR CI and alarm surfacing ─────────────────────────────────────── + # The watchdog files a ci-alarm issue for a red or missing scheduled run or a + # broken release (email went unread for 9 nights). Every open alarm, and a + # Watchdog workflow that is missing, disabled or without a success in 3 h, + # shows as a warning on every CI run. It never fails CI: the script exits 0, + # and the job is continue-on-error. + alarms: + name: Open CI alarms + runs-on: ubuntu-latest + timeout-minutes: 5 + continue-on-error: true + permissions: + issues: read + actions: read + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + steps: + - name: Warn on open ci-alarm issues and a quiet Watchdog + run: | + set +e + err=$(mktemp) + # Workflow-command data escapes %, CR and LF. + oneline() { tr '\r\n' ' ' < "$err" | sed 's/ *$//; s/%/%25/g'; } + if alarms=$(gh issue list --label ci-alarm --state open --limit 100 --json number,title,url,labels 2>"$err"); then + jq -r '.[] + | "open ci-alarm #\(.number): \(.title) \(.url) (\(if any(.labels[]; .name == "alarm-ack") then "acked" + else "NOT acked; after reading it: gh issue edit \(.number) --add-label alarm-ack" end))" + | "::warning::" + (gsub("%"; "%25") | gsub("\r"; "%0D") | gsub("\n"; "%0A"))' <<<"$alarms" + else + echo "::warning::could not list the open ci-alarm issues: $(oneline)" + fi + if ! wd=$(gh workflow list --all --json name,path,state \ + -q '[.[] | select(.name == "Watchdog")][0] // empty | "\(.state) \(.path)"' 2>"$err"); then + echo "::warning::could not list the workflows to check the Watchdog: $(oneline)" + elif [ -z "$wd" ]; then + echo "::warning::there is no Watchdog workflow, so a red or missing scheduled run raises no ci-alarm" + else + read -r state path <<<"$wd" + if [ "$state" != active ]; then + echo "::warning::the Watchdog workflow is $state, so a red or missing scheduled run raises no ci-alarm" + else + last=$(gh run list -w "${path##*/}" -s success -L 1 --json updatedAt -q '.[0].updatedAt // empty' 2>"$err") + ts=$(date -u -d "${last:-@0}" +%s 2>/dev/null) || ts=0 + if [ $(( $(date -u +%s) - ts )) -gt 10800 ]; then + echo "::warning::the Watchdog workflow has no successful run in the last 3 h (last: ${last:-none}); alarms may be missing" + fi + fi + fi + rm -f "$err" + exit 0 diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml deleted file mode 100644 index cd7a4ecf..00000000 --- a/.github/workflows/dependabot-auto-merge.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Auto-merge Dependabot PRs - -on: pull_request - -permissions: - contents: write - pull-requests: write - -jobs: - automerge: - if: github.actor == 'dependabot[bot]' - runs-on: ubuntu-latest - steps: - - name: Enable auto-merge (squash) - run: gh pr merge --auto --squash "$PR_URL" - env: - PR_URL: ${{ github.event.pull_request.html_url }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index d333cad2..50665fd6 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -41,6 +41,7 @@ jobs: image: name: Docs interpreter pin runs-on: ubuntu-latest + timeout-minutes: 10 outputs: ref: ${{ steps.pin.outputs.ref }} steps: @@ -58,6 +59,7 @@ jobs: name: Build (mkdocs --strict) needs: image runs-on: ubuntu-latest + timeout-minutes: 15 outputs: pages: ${{ steps.pages.outputs.enabled }} container: @@ -89,6 +91,7 @@ jobs: if: needs.build.outputs.pages == 'true' needs: build runs-on: ubuntu-latest + timeout-minutes: 15 permissions: pages: write id-token: write diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 419040bc..868bd818 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -45,6 +45,7 @@ jobs: deb-rpm: name: Build and attach .deb + .rpm runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Determine version id: ver @@ -85,6 +86,7 @@ jobs: homebrew: name: Update Homebrew formula runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Determine version id: ver @@ -147,6 +149,7 @@ jobs: needs: deb-rpm if: ${{ !inputs.dry_run }} runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Determine version id: ver diff --git a/.github/workflows/watchdog.yml b/.github/workflows/watchdog.yml new file mode 100644 index 00000000..9f52cb2e --- /dev/null +++ b/.github/workflows/watchdog.yml @@ -0,0 +1,68 @@ +name: Watchdog + +# hack/watchdog checks, hourly and after each listed workflow completes on main: +# scheduled workflows that went red or stopped succeeding, disabled workflows, +# main without CI or with red CI, release tags without their images, packages or +# AUR update, and autobump PRs stuck without green CI. Each finding is one issue +# labelled ci-alarm (hack/ci_alarm), assigned to the owner with an @mention; it +# closes itself once the finding clears. Dispatch with simulate_red=true to file +# the `simulate` alarm; the next run without it closes it. +# +# It reads main, never the triggering run's code. workflow_run passes only runs on +# this repository's main: branches: matches the triggering run's head branch name, +# which a fork's pull request can also call main, so the job's if: also requires +# the run to come from this repository. The hourly cron covers a missed event. + +on: + schedule: + - cron: '47 * * * *' + workflow_run: + workflows: + - 'CI' + - 'Auto-update, Build & Release' + - 'Auto-release AUR' + - 'Release packages (deb, rpm, Homebrew)' + - 'AUR Deploy' + - 'Docs' + - 'Canary' + - 'Bot sweep' + types: [completed] + branches: [main] + workflow_dispatch: + inputs: + simulate_red: + description: 'File the `simulate` alarm, to check that alarms reach the owner' + type: boolean + default: false + +permissions: + contents: read + issues: write + actions: write + pull-requests: read + +concurrency: + group: watchdog + cancel-in-progress: false + +jobs: + watchdog: + name: Watchdog + if: >- + github.event_name != 'workflow_run' + || (github.event.workflow_run.head_branch == 'main' + && github.event.workflow_run.head_repository.full_name == github.repository) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main + fetch-depth: 0 # the aur-v* tags and their dates + persist-credentials: false + - name: Check and alarm + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + SIMULATE_RED: ${{ inputs.simulate_red }} + run: hack/watchdog diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ab7bfb9d..07c76adc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -114,19 +114,32 @@ Include relevant details: (`tests/console/audit/default`), diffed against `tests/console/audit/*.golden` (`tests/audit_test.sh --update` rewrites those). - Changed an image in `share/`, the Dockerfile or an image pin in `scripts/k3d-gpu`? - Run `scripts/images-lock update` (it reads the registries) and commit - `images.lock`; CI's `scripts/images-lock check` fails until you do. -- Every image reference in the repo is pinned `name:tag@sha256:`. Write - the tag, run `scripts/images-lock repin ` (it reads the registries) to add - the digest; `tests/image_pins_test.sh` (`scripts/images-lock pins`) fails on a - tag-only or `:latest` reference that `images.unpinned` does not list with a - reason. A rendered component's image pins live in its `component.conf` - (`IMAGE_PINS`). The nightly `repin-images` job opens a PR when a registry moves - a digest under an unchanged tag. Its token cannot push `.github/workflows/`, so - it runs `repin --exclude '.github/workflows/*'` and a workflow keeps no - `name:tag@sha256` of its own: it reads the pin from the Dockerfile, - `docs-build/Dockerfile` or `hack/ci-images.env` (`tests/image_pins_test.sh` - fails on a pin written in a workflow). + Run `scripts/images-lock update --only ` (it reads the + registries; without `--only` it checks every pinned digest and a digest a registry + moved elsewhere fails it) and commit `images.lock`; CI's `scripts/images-lock + check` fails until you do. `scripts/images-lock drift` lists the pins whose tag a + registry has moved, and writes nothing. +- Every image reference in the repo is pinned `name:tag@sha256:`, the tag + naming the version. Write the tag, run `scripts/images-lock repin ` (it + reads the registries) to add the digest; `tests/image_pins_test.sh` + (`scripts/images-lock pins`) fails on a tag-only, `:latest` or digest-only + reference that `images.unpinned` does not list with a reason. A rendered + component's image pins live in its `component.conf` (`IMAGE_PINS`). The nightly + `repin-images` job opens a PR when a registry moves a digest under an unchanged + tag. Its token cannot push `.github/workflows/`, so it runs `repin --exclude + '.github/workflows/*'` and a workflow keeps no version of its own: it reads image + pins from the Dockerfile, `docs-build/Dockerfile` or `hack/ci-images.env`, and + tool versions from `hack/ci-tools.env` (`tests/image_pins_test.sh` and + `tests/ci_wiring_test.sh` fail on a pin or version written in a workflow). +- `pins/classes` says who may move each pin. `ours` (the default) is k3d-gpu's own + and tracks the newest stable release; `eat-stock` tracks Intel EAT's newest + release; a `ledger` path is a ledger row's runner: while a listed row is PENDING + its pins move only with a dated amendment in the same PR, and once every listed + row has a verdict it is `historical`, a finished run's record that is never edited. + `repin` never touches ledger or historical paths. CI's `pin-guard` job runs + `hack/pin-guard` on every PR (`tests/pin_guard_test.sh` shows each rule) and + `hack/pin-classes coverage`, which fails on a pin-bearing file under `bench/` or + `oracle/` that no line classifies: add one for a new bench directory. - `tests/console_mcp_test.sh` checks that the MCP tools `hardware` and `plan_launch` return the CLI `facts --json` and `plan --json` bytes on every fixture, and that the MCP `apply` tool writes nothing without a plan hash the diff --git a/EXPERIMENTS.md b/EXPERIMENTS.md index ed652e17..e31e5303 100644 --- a/EXPERIMENTS.md +++ b/EXPERIMENTS.md @@ -89,7 +89,7 @@ setup too weak to decide). Only `SUPPORTED` backs a claim. | H25w | H25's hypothesis (T part) on wildct: agents that send a byte-identical system prompt and tools to one OVMS CPU server reuse one prefix KV, so an added agent's first chunk arrives at least 4x sooner with the shipping prefix caching on than with `--enable_prefix_caching=false`. A replication: original model, prefix shape, probe, scorer and thresholds. | Written 2026-10-20, before any H25w measurement. Added-agent median TTFT OFF 10 s (7-15), ON 1.0 s (0.6-1.6), ratio 10x (6-14), CI lower bound >= 4x; c/OFF >= 0.8. SUPPORTED 0.8. | H25's `bench/shared-prefix/run.sh` and `probe.py score` (A3 first-chunk TTFT, A4 amendment-27 load rule), ON OFF interleaved 3+3 rounds at `typical`, unchanged except as listed in the wildct pre-registration: cluster `k3dgpu-w-h25`, the GPU opt-out, rule W2 (load on E-cores 20-23 only), W3, W4, new seeds (`SEED_BASE=2560`). | PENDING | | H28w | H28's hypothesis on wildct: N = 8 agent pods that mmap one 1 GiB model through an image volume share one page-cache copy and become ready in at most half the time of pods that copy it into an emptyDir. A replication (H28 has no model; the 1 GiB blob is kept). | Written 2026-10-20, before any H28w measurement. Memory parts hold (0.95): T PSS <= 160 MiB per pod, model page cache about 1.0x size (T) vs about 9x (C). Time part: T/C round-median ready 0.5 (0.4-0.65), against H28's 0.47 on the i9. SUPPORTED 0.45, REFUTED (time part) 0.4, INCONCLUSIVE or not run (disk) 0.15. | H28's `bench/zero-copy-models/probe.sh` (A1, A2), arms C T T C C T plus the unscored warm-up, `typical` only, scored by `bench/zero-copy-models/score.py`, unchanged except as listed in the wildct pre-registration: cluster `k3dgpu-w-h28`, node on the P-cores (`NODE_CPUS=0-15`), W2, W3 (passed to the plain `k3d cluster create`), and a disk gate (W6). | PENDING | | H34w | H34's hypothesis on wildct: same-engine CPU Fast Draft (OVMS 2026.4.1, `OpenVINO/Qwen3-0.6B-int4-ov` draft on the CPU) beats the `OpenVINO/Qwen3-4B-int4-ov` target alone on single-stream decode, both on the 16-CPU P-core balloon, under matched background load. A replication: original models, runner, scorer and rule. | Written 2026-10-20, before any H34w measurement. At `typical`: median paired B/A decode tok/s 1.35 (1.15-1.6) and median paired B/A package J/token 0.76 (0.6-0.92). SUPPORTED by H34's rule at 0.6, REFUTED 0.25, INCONCLUSIVE 0.15. | H34's `bench/hetero-spec/h34.sh` and `h34.py`, n = 12 pairs A B / B A, 5 s load window per request, `typical` only, unchanged except as listed in the wildct pre-registration: cluster `k3dgpu-w-h34`, the GPU opt-out in place of H34's `intel,hybrid`, W2, W3, W4. | PENDING | -| H57wa | H57da's hypothesis on wildct (i7-13700, the 16 P-core CPUs, AVX2 and AVX-VNNI, no AVX-512, no AMX): with the Gemma 4 26B-A4B QAT family, k3d-gpu's own CPU pick Od (`agent manifest` auto on the P-cores: OVMS 2026.4.1, `OpenVINO/gemma-4-26b-a4b-it-int4-ov`) is the fastest single-stream arm: no other gate-passing arm (llama.cpp with Google's QAT Q4_0 GGUF, with or without the QAT MTP drafter) decodes at N=1 5% or more faster. A cross-host replication of H57da on a second AMX-less client. | Written 2026-10-20, before any H57d or H57w measurement. N=1 median decode tok/s: Od in [7, 18] (0.5); Ld in [10, 26] (0.5); Ld/Od in [1.1, 1.8] (0.5); every arm's GSM8K 0-49 score >= 0.80 (0.6). REFUTED (0.55), SUPPORTED (0.25), INCONCLUSIVE (0.2). Not gated: TTFT on a 1,000-token prompt, aggregate tok/s at N=4, package J/token. | H57da's protocol, runner, statistic and outcome rules (the H57d pre-registration and its runner amendment), unchanged except as listed in the wildct pre-registration: H57d's runner (amendment R1) with the host knobs listed there: clusters `k3dgpu-w-h57d-`, the GPU opt-out in place of `K3D_GPU_VENDORS=hybrid`, W2-W6, hold-start `load1` <= 12, models downloaded on wildct at H57d's revisions, at most 5 holds of at most 75 min. | PENDING | +| H57wa | H57da's hypothesis on wildct (i7-13700, the 16 P-core CPUs, AVX2 and AVX-VNNI, no AVX-512, no AMX): with the Gemma 4 26B-A4B QAT family, k3d-gpu's own CPU pick Od (`agent manifest` auto on the P-cores: OVMS 2026.4.1, `OpenVINO/gemma-4-26b-a4b-it-int4-ov`) is the fastest single-stream arm: no other gate-passing arm (llama.cpp with Google's QAT Q4_0 GGUF, with or without the QAT MTP drafter) decodes at N=1 5% or more faster. A cross-host replication of H57da on a second AMX-less client. | Written 2026-10-20, before any H57d or H57w measurement. N=1 median decode tok/s: Od in [7, 18] (0.5); Ld in [10, 26] (0.5); Ld/Od in [1.1, 1.8] (0.5); every arm's GSM8K 0-49 score >= 0.80 (0.6). REFUTED (0.55), SUPPORTED (0.25), INCONCLUSIVE (0.2). Not gated: TTFT on a 1,000-token prompt, aggregate tok/s at N=4, package J/token. | H57da's protocol, runner, statistic and outcome rules (the H57d pre-registration and its runner amendment), unchanged except as listed in the wildct pre-registration: H57d's runner (amendment R1) with the host knobs listed there: clusters `k3dgpu-w-h57d-`, the GPU opt-out in place of `K3D_GPU_VENDORS=hybrid`, W2-W6, hold-start `load1` <= 12, models downloaded on wildct at H57d's revisions, at most 5 holds of at most 75 min. Od's render names the hold's cluster: [H57d amendment R2](#h57d-amendment-r2-ods-render-names-the-holds-cluster-2026-10-29) (2026-10-29, before any H57w hold). | PENDING | | H57wm | H57dm's hypothesis on wildct: llama.cpp Ld-mtp (Ld plus Google's QAT MTP assistant converted to Q4_0 GGUF, `--spec-type draft-mtp`, default draft length) decodes at N=1 at least 1.20x Ld with the quality gate passed. | Written 2026-10-20. Ld-mtp/Ld in [0.8, 1.3] (0.6). REFUTED (0.65), INCONCLUSIVE (0.15), SUPPORTED (0.2). | As H57wa (same holds and records); H57dm's statistic and rule. | PENDING | | H57wb | H57db's hypothesis on wildct: inside EAT's banking flow (B24) with Gemma 4 26B-A4B QAT, k3d-gpu auto on the P-cores delivers far more tasks within budget than EAT stock CPU (vLLM CPU bf16 on the QAT bf16 weights). | Written 2026-10-20: not runnable here as registered (disk and memory), so INCONCLUSIVE (not run) is expected (0.95). | H57db's design and gates; runs only if at a hold's start / has >= 15 + 70 GB available (51.6 GB of bf16 weights plus images) and MemAvailable >= 100 GiB; otherwise INCONCLUSIVE (not run), with both readings recorded and no bf16 download. | PENDING | | F2a | Open WebUI in a k3d-gpu cluster reaches a public remote endpoint by name: `k3d-gpu quickstart --remote https://gaudi.farmgpu.net` (no hostAliases) gives a chat through Open WebUI's own API that returns. An earlier run saw `Temporary failure in name resolution` for gaudi.farmgpu.net from a pod. | Pre-registered 2026-10-17, before measuring: the in-cluster failure is a cluster DNS gap (CoreDNS forwarding to an upstream the node container cannot use), not the endpoint; once fixed, one `POST /api/chat/completions` returns `finish_reason=stop` with non-empty content (0.7). | Fresh cluster `k3dgpu-f2-*` (scratch KUBECONFIG, node CPUs 8-15), `nslookup` from a pod before any change, CoreDNS Corefile and node `/etc/resolv.conf` read; root cause fixed in the product if it is a k3d-gpu config gap (regression test first); then one chat through Open WebUI's API with the generated admin account, max_tokens <= 64, temperature 0.6. SUPPORTED: chat returns. REFUTED: no chat returns after the fix. Run (2026-10-17, i9-13900T, k3s v1.35.5, node CPUs 8-15): the prediction's mechanism was wrong. CoreDNS forwarding works (node resolv.conf is k3d's 192.168.32.1 DNAT to Docker's 127.0.0.11; busybox and glibc lookups from pods resolve). The failure is a startup race: `k3d-gpu up` returned on node Ready, when the CoreDNS Deployment was 1 s old (image still pulling); a glibc pod started then got `gaierror(-3, 'Temporary failure in name resolution')` for 11 s (4 tries), first success at 13 s. Fix: `up` waits for the CoreDNS rollout; after it, the same pod resolved on its first try (0.0 s). Then `quickstart --remote https://gaudi.farmgpu.net` with no hostAliases: Open WebUI listed `zai-org/GLM-5.3` and one `POST /api/chat/completions` returned finish_reason=stop, content 'OK', 0.41 s; 0 name resolution errors in its log. | SUPPORTED | @@ -103,9 +103,9 @@ setup too weak to decide). Only `SUPPORTED` backs a claim. | H7x | (Arms B and C superseded by [amendment V1](#h7x-amendment-v1-eat-release-110-for-arms-b-and-c-2026-10-29): EAT release-1.1.0's stock, vLLM CPU v0.28.0; H7's vLLM version is no longer kept.) H7's hypothesis on the Xeon 6741P, run to H7's full protocol: OVMS's decode lead over EAT's vLLM CPU engine comes from weight precision, not from the engine. One checkpoint (`Qwen/Qwen3-4B` @ `1cfa9a72`), OVMS 2026.4.1 on its 16-bit IR (`OpenVINO/Qwen3-4B-fp16-ov` @ `00298f1c`) and vLLM CPU v0.24.0 in bf16 with EAT's chart defaults decode within 15% of each other, and land within 15% in effective weight bandwidth. A replication: H7's models and versions are kept. | Written 2026-10-20, before any H7x measurement. On AMX both engines compute in bf16 and batch-1 decode reads the same 8.0 GB per token, so the engine gap shrinks: R = OVMS/vLLM decode 1.10 (0.85-1.40); SUPPORTED 0.45, REFUTED 0.30, INCONCLUSIVE 0.25. Reported, not scored: prefill TTFT on ~~\~1936~~ ~1850 tokens (corrected in the 2026-10-21T22:30Z revision to the prompt `h7x.sh` has always sent, 56 copies of one sentence; value unchanged; struck through 2026-10-29) vLLM/OVMS 1.5 (0.7-4; the i9 gap of 4.3x was outside H7's prediction), C/B decode (`--enforce-eager` dropped) 1.0-1.3, decode incremental J/token per arm. | `bench/apb/h7x.sh` (H7's `h7.sh` extended to the pre-registered protocol: H2's workloads and energy method, blocks A B B A A B B A A B, then one C block with `--enforce-eager` dropped), scored by `bench/apb/h7x.py`; decision rule, host changes and hold in [H7x pre-registration](#h7x-pre-registration-h7-to-its-full-protocol-on-the-xeon-6741p-2026-10-20). B and C are EAT release-1.1.0's stock (`0c726f57`, vLLM CPU v0.28.0), after a further setup smoke, per [amendment V1](#h7x-amendment-v1-eat-release-110-for-arms-b-and-c-2026-10-29). | PENDING | | H55 | On the i9-13900T, an SLO gate on the E-core neighbour's `cpu.max`, set from the P-core inference server's own median TTFT, keeps CPU-served prefill within +10% of quiet while the neighbour keeps more throughput than H5's frequency-cap frontier (CMIN: +23.3% at 72.4% neighbour loss) and than the best static quota at the same penalty. | Written 2026-10-20, before any H55 measurement; control rule revised the same day, still before any measurement (see the section). Pre-registered prior, not a result: N0 about +40% (H5 replicate), K50 +20 to +30% at about 50% loss, K25 +8 to +15% at about 75% loss. A_slo climbs only while the penalty at its level is at most +5%, so it is expected to stay at the 25-37.5% levels, close to K25: +8 to +15% at 70-78% loss, failing (b) or (c). Expected verdict REFUTED: package power is the coupling, duty-cycling the neighbour is less power-efficient per op than H5's frequency cap, and a closed-loop c = 1 prefill load leaves no idle gap to admit it into. If H55 holds, A_slo settles at 50% or above at +10% or less. | H5 amendment A1's host-level harness (OVMS 2026.4.1, `OpenVINO/Qwen3-4B-int4-ov`, `bench/prefill-guard/prompt.txt` with a unique tag per request, CPUs 0-14; `stress-ng --cpu 15 --cpu-method matrixprod` on CPUs 17-31; tool proxy on CPU 16), with a 900 s closed-loop prefill window per run (16 tokens, concurrency 1). Arms Q, N0 (`cpu.max max`), K50 (`750000 100000`), K25 (`375000 100000`) and A_slo (`bench/admission/ctl.py` steps 25-100% of 15 CPUs on windows of 20 requests: down if the window median > 1.10 x the round's quiet reference, the median of 20 pre-roll requests; up if it is at most 1.05 x that), 5x5 Latin square, one round per lock hold. Supported only if A_slo's median penalty is at most +10%, its neighbour loss is below 72.4%, and no static arm reaches +10% at a loss no higher than A_slo's plus the neighbour's own N0 run-to-run spread; inconclusive gates and the hold cap in [H55 pre-registration](#h55-pre-registration-slo-gated-admission-of-an-e-core-neighbour-2026-10-20). Not run. A SUPPORTED H55 does not ship a controller by itself: kubelet owns the sandbox pod's `cpu.max` (`limits cpu "1"`). | PENDING | | H56 | Two measurement holds in disjoint cpuset slices of the Xeon 6741P do not perturb each other: an A1 B24 agent block in slice 0 (CPUs 0-23,48-71) behaves equivalently, in per-attempt time, within-budget success and tasks completed, alone and with a concurrent identical A1 hold in slice 1 (CPUs 24-47,72-95), without an all-core clock drop on slice 0 and with the package below its power limit. It decides whether crescent may run two A1-or-lighter holds at once ([adoption rule](#h56-pre-registration-parallel-slice-holds-on-crescent-2026-10-20)); it is not a k3d-gpu product claim. | Written 2026-10-20, before any run; predictions unchanged by the 2026-10-21 revision, priors updated to its narrower bands. Geometric-mean W/A time ratio 1.04 (0.99-1.09); paired success W - A -0.01 (-0.06 to +0.04); completion W/A 0.97 (0.90-1.00); own-slice MHz W/A 0.97; W package 0.75 PL1 (0.55-0.95), A package 0.5 PL1; time pairs 80 (50-96). Batch-1 int4 decode is DRAM-bound, and the sibling's decode and its stream load share DRAM, L3 and package power. Verdict priors: SUPPORTED 0.30, INCONCLUSIVE 0.45, REFUTED 0.25. | Stage 0 first, read-only: `bench/eatflow/crescent/lockwait.py` over both campaigns' `campaign.log`; if lock wait our slots could remove is under 0.10 of hold time, stage 1 is not run and the row stays PENDING (`h56.sh` refuses through `h56.py gate`). [Amendment S0](#h56-pre-registration-parallel-slice-holds-on-crescent-2026-10-20) (2026-10-21, before any stage-1 hold, stage-0 reading disclosed): the share is pooled lock wait over hold time across every campaign log on crescent, nothing netted out; bar 0.10 unchanged. Then one W wiring smoke, which must show slice-0 setup <= 900 s and >= 3 tasks in 120 s. Stage 1: `bench/eatflow/crescent/h56.sh N` for N = 1..8, order A W W A A W W A, one condition-hold per exclusive campaign lock (slot locks first). A: slice 0 alone, slice 1 idle. W: the sibling in slice 1 first (cap 3000 s, budget 1800 s), slice 0 from the sibling's run budget on (cap 2400 s, budget 720 s, the same as A). Hold pairs (1,2) (4,3) (5,6) (8,7), tasks by id; 90% two-stage bootstrap CIs (hold pairs, then tasks; 10,000, seed 56; TOST at alpha 0.05). SUPPORTED: all 8 positions filled, time CI inside [1/1.05, 1.05], success CI inside [-1/24, +1/24], completion W/A >= 1/1.05, >= 60 time pairs, own-slice MHz W/A >= 0.95, W package < 0.95 PL1 and PL1 read. REFUTED: the time CI wholly outside its band, the success CI wholly outside its band, or the completion CI wholly below 1/1.05. Otherwise INCONCLUSIVE. VOID holds are replaced, at most 2. `bench/eatflow/crescent/h56.py score`. Stage 0 under amendment S0 (`lockwait.json`, 62fdf617): pooled share 0.9987 (16081 s of lock wait over 16102 s held, 21 holds, 4 logs), so stage 1 ran; smoke-W passed `h56.py gate` (slice-0 setup 225 s, 15 tasks in 120 s). Stage 1 run 2026-10-21, holds 1-8 in order, every rc 0, none gate-flagged ([H56 run](#h56-run-stage-1-xeon-6741p-2026-10-21)): `h56.py score` reads "4 VOID condition-holds > 2; position 2 missing; position 3 missing; position 6 missing; position 7 missing". Every W hold is VOID on one rule, slice 0's block window not inside the sibling's: a B24 block ends at its 24th task, not at its run budget, and took 178-190 s, so the sibling's block (1800 s budget) ended 27-32 s after slice 0's began and covered 0.15-0.17 of it. No time, success, completion, MHz or power statistic is scored. The 4 A holds are valid (22/24 passed in each). A VOID replacement cannot lift the verdict (4 VOID > 2) and would VOID by the same timing, so no further H56 hold is owed; crescent rows stay K=1 (adoption rule 1). | INCONCLUSIVE | -| H59 | Engine choice by evidence on the Xeon 6741P (crescent; AMX, CPU only, no card): with Gemma 4 26B-A4B QAT at agent concurrency N=4, llama.cpp with Google's QAT Q4_0 GGUF and the QAT MTP drafter (L, `agent up` with the llama.cpp backend of PR #182) decodes at least 1.2x faster than OVMS int4 (O, `OpenVINO/gemma-4-26b-a4b-it-int4-ov`, the model optimizer's current pick), with GSM8K quality within 2 of 50. SUPPORTED makes llama.cpp the optimizer's CPU engine default for this CPU class (`console/ledger-rules.json`, H59 family); otherwise OVMS stays. | Written 2026-10-21, before any H59 measurement. Informed by H54a's descriptive N=1 numbers on this host (Lg-mtp 54.52, A1g 23.25 tok/s, not a verdict): N=1 L/O 2.2 (1.6-2.8) (0.6); N=4 per-request decode L/O 1.6 (1.1-2.3) (0.55), with MTP's gain smaller at N=4 than at N=1 (0.65); N=16 L/O < N=4 L/O (0.6); prefix-reuse added-agent TTFT O < L (0.55); GSM8K both >= 46/50 (0.7). SUPPORTED (0.55), REFUTED (0.3), INCONCLUSIVE (0.15). | [H59 pre-registration](#h59-pre-registration-engine-choice-per-cpu-class-2026-10-21): arms O and L at their best documented settings, one serving at a time on the CPU, 3 performance holds in rotated order (N=1: 8, N=4: 16, N=16: 32 requests, 256 tokens, greedy), a TTFT and a prefix-reuse phase per arm, 1 quality hold (GSM8K 0-49), RAPL J/token. Statistic: ratio of L's to O's median per-request decode tok/s at N=4 over the pooled holds, 95% bootstrap CI (H54's `boot_ratio`). SUPPORTED: CI lower bound >= 1.2 and L's GSM8K >= O's - 2. REFUTED: CI upper bound < 1.2, or the quality gate fails. Otherwise INCONCLUSIVE (also: an arm not deliverable, load unmatched). Scorer: `bench/h59/h59.py`, [H59 amendment S1](#h59-amendment-s1-the-scorer-and-the-desktop-and-wildct-priors-after-the-h57d-run-2026-10-28) (2026-10-28, before any H59 measurement). O serves base-model int4 (OVB), not QAT weights, so a verdict compares the two stacks as shipped (S1's description fixes); SUPPORTED also needs L faster than O in each performance hold: [H59 amendment S2](#h59-amendment-s2-supported-needs-l-faster-in-each-performance-hold-2026-10-29) (2026-10-29, before any H59 measurement). | PENDING | -| H59d | H59 on the desktop i9-13900T (8 P-cores, AVX2 and AVX-VNNI, no AVX-512, no AMX): same arms, statistic and rule; SUPPORTED makes llama.cpp the CPU engine default for the i9-13900T class only. | Written 2026-10-21, before any H59d measurement; H57d's desktop arms (Od, Ld, Ld-mtp) have no scored result yet. N=1 L/O 1.4 (0.9-2.0) (0.5); N=4 L/O 1.2 (0.8-1.7) (0.45). SUPPORTED (0.35), REFUTED (0.45), INCONCLUSIVE (0.2). | As H59, with the desktop knobs of the H57d pre-registration (P-core balloon, its load gate, H57d's lock and disk rules); N=16 is not run (8 physical P-cores; H57d dropped it for the same reason). Prior restated 2026-10-28 after the H57d run, before any H59d measurement, prediction unchanged; scorer `bench/h59/h59.py --load h57d`: [H59 amendment S1](#h59-amendment-s1-the-scorer-and-the-desktop-and-wildct-priors-after-the-h57d-run-2026-10-28). SUPPORTED also needs L faster than O in each performance hold: [H59 amendment S2](#h59-amendment-s2-supported-needs-l-faster-in-each-performance-hold-2026-10-29) (2026-10-29, before any H59d measurement). | PENDING | -| H59w | H59 on wildct, the client i7-13700 (8 P-cores, AVX2 and AVX-VNNI, no AVX-512, no AMX): same arms, statistic and rule; SUPPORTED makes llama.cpp the CPU engine default for the i7-13700 class only. | Written 2026-10-21, before any H59w measurement. N=1 L/O 1.4 (0.9-2.0) (0.5); N=4 L/O 1.2 (0.8-1.7) (0.45). SUPPORTED (0.35), REFUTED (0.45), INCONCLUSIVE (0.2). | As H59d, with the wildct knobs of the wildct pre-registration (the GPU opt-out, rules W2-W6, hold-start `load1` <= 12). Prior restated 2026-10-28 after the H57d run, before any H59w measurement, prediction unchanged; scorer `bench/h59/h59.py --load h57d`: [H59 amendment S1](#h59-amendment-s1-the-scorer-and-the-desktop-and-wildct-priors-after-the-h57d-run-2026-10-28). SUPPORTED also needs L faster than O in each performance hold: [H59 amendment S2](#h59-amendment-s2-supported-needs-l-faster-in-each-performance-hold-2026-10-29) (2026-10-29, before any H59w measurement). | PENDING | +| H59 | Engine choice by evidence on the Xeon 6741P (crescent; AMX, CPU only, no card): with Gemma 4 26B-A4B QAT at agent concurrency N=4, llama.cpp with Google's QAT Q4_0 GGUF and the QAT MTP drafter (L, `agent up` with the llama.cpp backend of PR #182) decodes at least 1.2x faster than OVMS int4 (O, `OpenVINO/gemma-4-26b-a4b-it-int4-ov`, the model optimizer's current pick), with GSM8K quality within 2 of 50. SUPPORTED makes llama.cpp the optimizer's CPU engine default for this CPU class (`console/ledger-rules.json`, H59 family); otherwise OVMS stays. | Written 2026-10-21, before any H59 measurement. Informed by H54a's descriptive N=1 numbers on this host (Lg-mtp 54.52, A1g 23.25 tok/s, not a verdict): N=1 L/O 2.2 (1.6-2.8) (0.6); N=4 per-request decode L/O 1.6 (1.1-2.3) (0.55), with MTP's gain smaller at N=4 than at N=1 (0.65); N=16 L/O < N=4 L/O (0.6); prefix-reuse added-agent TTFT O < L (0.55); GSM8K both >= 46/50 (0.7). SUPPORTED (0.55), REFUTED (0.3), INCONCLUSIVE (0.15). | [H59 pre-registration](#h59-pre-registration-engine-choice-per-cpu-class-2026-10-21): arms O and L at their best documented settings, one serving at a time on the CPU, 3 performance holds in rotated order (N=1: 8, N=4: 16, N=16: 32 requests, 256 tokens, greedy), a TTFT and a prefix-reuse phase per arm, 1 quality hold (GSM8K 0-49), RAPL J/token. Statistic: ratio of L's to O's median per-request decode tok/s at N=4 over the pooled holds, 95% bootstrap CI (H54's `boot_ratio`). SUPPORTED: CI lower bound >= 1.2 and L's GSM8K >= O's - 2. REFUTED: CI upper bound < 1.2, or the quality gate fails. Otherwise INCONCLUSIVE (also: an arm not deliverable, load unmatched). Scorer: `bench/h59/h59.py`, [H59 amendment S1](#h59-amendment-s1-the-scorer-and-the-desktop-and-wildct-priors-after-the-h57d-run-2026-10-28) (2026-10-28, before any H59 measurement). O serves base-model int4 (OVB), not QAT weights, so a verdict compares the two stacks as shipped (S1's description fixes); SUPPORTED also needs L faster than O in each performance hold: [H59 amendment S2](#h59-amendment-s2-supported-needs-l-faster-in-each-performance-hold-2026-10-29) (2026-10-29, before any H59 measurement). The runner: [H59 amendment R1](#h59-amendment-r1-the-runner-and-the-host-profiles-2026-10-29) (2026-10-29, before any H59 measurement). | PENDING | +| H59d | H59 on the desktop i9-13900T (8 P-cores, AVX2 and AVX-VNNI, no AVX-512, no AMX): same arms, statistic and rule; SUPPORTED makes llama.cpp the CPU engine default for the i9-13900T class only. | Written 2026-10-21, before any H59d measurement; H57d's desktop arms (Od, Ld, Ld-mtp) have no scored result yet. N=1 L/O 1.4 (0.9-2.0) (0.5); N=4 L/O 1.2 (0.8-1.7) (0.45). SUPPORTED (0.35), REFUTED (0.45), INCONCLUSIVE (0.2). | As H59, with the desktop knobs of the H57d pre-registration (P-core balloon, its load gate, H57d's lock and disk rules); N=16 is not run (8 physical P-cores; H57d dropped it for the same reason). Prior restated 2026-10-28 after the H57d run, before any H59d measurement, prediction unchanged; scorer `bench/h59/h59.py --load h57d`: [H59 amendment S1](#h59-amendment-s1-the-scorer-and-the-desktop-and-wildct-priors-after-the-h57d-run-2026-10-28). SUPPORTED also needs L faster than O in each performance hold: [H59 amendment S2](#h59-amendment-s2-supported-needs-l-faster-in-each-performance-hold-2026-10-29) (2026-10-29, before any H59d measurement). The runner: [H59 amendment R1](#h59-amendment-r1-the-runner-and-the-host-profiles-2026-10-29) (2026-10-29, before any H59d measurement). The load gate reads each arm's 60 s idle window; the N=4 reading is recorded, not gated: [H59 amendment R2](#h59-amendment-r2-h59ds-and-h59ws-load-gate-reads-each-arms-idle-window-2026-10-31) (2026-10-31, the owner's decision of 2026-10-29, before any H59d measurement). | PENDING | +| H59w | H59 on wildct, the client i7-13700 (8 P-cores, AVX2 and AVX-VNNI, no AVX-512, no AMX): same arms, statistic and rule; SUPPORTED makes llama.cpp the CPU engine default for the i7-13700 class only. | Written 2026-10-21, before any H59w measurement. N=1 L/O 1.4 (0.9-2.0) (0.5); N=4 L/O 1.2 (0.8-1.7) (0.45). SUPPORTED (0.35), REFUTED (0.45), INCONCLUSIVE (0.2). | As H59d, with the wildct knobs of the wildct pre-registration (the GPU opt-out, rules W2-W6, hold-start `load1` <= 12). Prior restated 2026-10-28 after the H57d run, before any H59w measurement, prediction unchanged; scorer `bench/h59/h59.py --load h57d`: [H59 amendment S1](#h59-amendment-s1-the-scorer-and-the-desktop-and-wildct-priors-after-the-h57d-run-2026-10-28). SUPPORTED also needs L faster than O in each performance hold: [H59 amendment S2](#h59-amendment-s2-supported-needs-l-faster-in-each-performance-hold-2026-10-29) (2026-10-29, before any H59w measurement). The runner: [H59 amendment R1](#h59-amendment-r1-the-runner-and-the-host-profiles-2026-10-29) (2026-10-29, before any H59w measurement). The load gate reads each arm's 60 s idle window; the N=4 reading is recorded, not gated: [H59 amendment R2](#h59-amendment-r2-h59ds-and-h59ws-load-gate-reads-each-arms-idle-window-2026-10-31) (2026-10-31, the owner's decision of 2026-10-29, before any H59w measurement). | PENDING | ## Falsification log @@ -7147,6 +7147,32 @@ drafter, records, 30 GB) and the four images it pulled are removed. cpufreq was `bench/prefill-guard/cpufreq.sh snapshot` before the downloads and after hold 2 are byte-identical (and equal H5's campaign snapshot). +### H57d amendment R2: Od's render names the hold's cluster (2026-10-29) + +Recorded 2026-10-29, before any H57wa or H57wm hold (no wildct hold has measured anything). It +governs every later hold of H57d's runner. Mechanics only: no row's arms, models, statistic, gate, +threshold, hold count or prediction changes. + +**What it fixes.** `bench/h57d/arm.sh` renders Od by running `scripts/k3d-gpu agent manifest` +against the hold's own kubeconfig. Since PR #183 (`a7f95676`, 2026-10-21, after H57d's holds), +every kubectl call of `k3d-gpu` names the context `k3d-${K3D_GPU_CLUSTER:-gpu}`. `arm.sh` did not +set `K3D_GPU_CLUSTER`, so the render asked for context `k3d-gpu`, which a hold's kubeconfig does +not hold. Its node queries failed without a word, and Od rendered with no `k3d-gpu.io/cores: +performance` label and a 1-CPU request (H57d's Od: 16). H57dp's two holds ran so; the H57dp run +section above discloses it as a deviation. + +`arm.sh` now passes `K3D_GPU_CLUSTER=${CLUSTER}` on that call. Nothing else of the render +changes. H57w's Od would have rendered the same way, since its holds run this runner. With the fix +it gets the P-core balloon and the 16-CPU request H57d's Od had, which the wildct pre-registration +states for H57w ("Od's 16-CPU request"). + +A regression test, `OdRendersInTheHoldsCluster` in `bench/h57d/test_h57d.py`, runs `arm.sh` for +Od against the fit fixtures. Its kubectl stand-in answers only for the hold's context. The test +reads the P-core label and 16 CPUs in the render; before the fix it read no label and 1 CPU. H59's +runner sets the same variable on both of its arms +([H59 amendment R1](#h59-amendment-r1-the-runner-and-the-host-profiles-2026-10-29)) and also +refuses an arm whose render lacks the balloon. + ### Wildct pre-registration: client Raptor Lake (i7-13700, 2026-10-20) Written 2026-10-20, before any H52w, H18w, H25w, H28w, H34w or H57w measurement on this host. @@ -7524,6 +7550,101 @@ says. Tests: `tests/wildct_hold_test.sh` (smoke3's pods and node; `nfprobe.sh`'s names the next smoke `smoke4`, adds `restarts=0` and every pod ready to its pass, and leaves `/etc/modules-load.d/k3dgpu-wildct.conf` to the box owner at the end of the campaign. +### Wildct amendment R4: the second module load, and H10's native nftables expressions (2026-10-29) + +Recorded 2026-10-29, before any wildct hold has measured anything. No row's hypothesis, +prediction, arm, model, statistic, threshold, cap, disk need, hold count or order changes. No +smoke ran. + +**What happened (disclosed).** The box owner approved loading and persisting the modules behind +H10's missing extensions and set types (amendment R3): `xt_comment`, `xt_statistic`, +`xt_multiport`, `xt_limit`, `xt_NFLOG`, `xt_physdev`, `xt_pkttype`, `ipt_REJECT`, `ip6t_REJECT`, +`ip_set_hash_ip`, `ip_set_hash_net`, `ip_set_hash_ipport`, `ip_set_hash_ipportip` and +`ip_set_hash_ipportnet`. They were loaded at 21:16:54Z (`modprobe -a`, rc 0), with three hard +dependencies (`nfnetlink_log`, `nf_reject_ipv4`, `nf_reject_ipv6`), and appended to +`/etc/modules-load.d/k3dgpu-wildct.conf`; `kernel.modprobe` stays empty. As in R3, the host's own +services were read before the load, at once after it, 3 minutes later, and again after the probes +below: every container's state, health, restart count and start time, the HTTP status of eight +published endpoints on the host (seven of them also through its tailnet address) and Prometheus's +two scrape targets read the same each time, and the firewall's rule counts and rule hashes did +not change (`bench/wildct/results/2026-10-29/host/modules-netfilter.txt`). Main at `e5748cd1` was +shipped (its launcher and `share/` equal 0.1.79's) and step 1 of the resume procedure ran again +(21:21:50Z; `runs/resume-health-r4.txt`). H1-H9 passed (Docker 29.7.2, `/` 208 GB available, +MemAvailable 118 GiB, H9 `br_netfilter vxlan`). H10 read `NFPROBE missing: NFLOG limit` (rc 1): +every other extension and set type now works, and the `limit` and `NFLOG` rules pass +iptables-nft's extension check, then fail with "RULE_APPEND failed (No such file or directory)". +So `smoke4` did not run, and no cluster was created. + +**Diagnosis.** iptables-nft writes `-m limit` and `-j NFLOG` as nftables' native `limit` and `log` +expressions, so those two rules need `nft_limit` and `nft_log` as well as the `xt_` modules. +nf_tables answers ENOENT for an expression type it does not have and cannot load. Both modules +are on disk for 6.6.156-2-MANJARO and not loaded. A diagnostic in a throwaway network namespace, +with the host's `nft` (`diag/nftexpr.sh.txt`; record `runs/nftexpr.txt`), added a counter rule +and a meta mark rule, and failed the limit and log rules the same way; `/proc/modules` read the +same before and after. k3s's network policy controller (kube-router) writes its drop-logging rule +in each pod's firewall chain with both (`-j NFLOG --nflog-group 100 -m limit --limit 10/minute`). +Restoring `kernel.modprobe`, or loading `nft_limit` and `nft_log`, is the box owner's call. The +next smoke is still `smoke4`. + +**Resume procedure.** `RESUME.md` records the second load, names `nft_limit` and `nft_log` next to +`xt_limit` and `xt_NFLOG` in H10's fail column, and lists the read-only check scripts left in +`diag/`. No runner changes. + +### Wildct amendment R5: the third module load, release 0.1.81 and smoke4 (2026-10-30) + +Recorded 2026-10-30 (UTC; 2026-10-29 in the box owner's time zone), before any wildct hold has +measured anything. No row's hypothesis, prediction, arm, model, statistic, threshold, cap, disk +need, hold count or order changes. No runner changes. + +**What happened (disclosed).** After amendment R4, the box owner approved loading and persisting +netfilter modules for this campaign as a class (`nf_`, `nft_`, `xt_`, `ipt_`, `ip6t_`, `ip_set`), +each load under the same snapshot, check and rollback procedure, with `kernel.modprobe` left +empty. `nft_limit` and `nft_log` were loaded at 05:02:48Z (`modprobe -a`, rc 0; each depends only +on `nf_tables`, already loaded) and appended to `/etc/modules-load.d/k3dgpu-wildct.conf`, which +now lists 18 modules, every one loaded. The host's 18 running containers (state, health, start +time, restart count), the HTTP status of its 15 published ports and the line counts of +`iptables-save` and `nft list ruleset` read the same before the load, at once after it and 2 min +18 s later; this snapshot did not read Prometheus's targets or the tailnet address, which R3 and +R4 also read (`bench/wildct/results/2026-10-30/host/modules-nft.txt`). `nfprobe.sh` with the +0.1.79 node image then read `NFPROBE clean`. + +The resume procedure then ran from main at `cf49f0e6`. Step 3a: rule W7's newest release is +0.1.81; its launcher (apart from the version line) and `share/` equal main's. Step 3b shipped +main. Step 3c: `.deb` sha256 `beac5c26...`, node image +`cryptoandcoffee/k3d-gpu:0.1.81@sha256:b68387c9...`, k3d v5.9.0, helm v4.3.1, kubectl v1.37.2; the +tag resolves to the digest, and the installed launcher and `share/` match main's. +`cryptoandcoffee/k3d-gpu:0.1.79`, pulled by the campaign, was removed. Steps 2 and 4 were done on +2026-10-29 and did not run again. Step 1 (05:09:52Z; `runs/resume-health-r5.txt`): H1-H10 pass: +Docker 29.7.2, live-restore off, cgroup systemd/2, `/` 208 GB available, MemAvailable 118 GiB, +`load1` 0.79, H9 `br_netfilter vxlan` with `modprobe=[]`, and H10 `NFPROBE clean` (rc 0) with the +0.1.81 node image. + +The wiring smoke `smoke4` (05:10:53Z, under `hold.sh`, rc 0) passed every marker of the resume +procedure: `up rc=0 in 30 s`, `pods ready: yes after 16 s` (`ready.sh`: the node Ready, every pod +n/n Running or Completed), `GPU-SCAN clean` and `down rc=0`; nvidia and intel named as opted out; +the node container had `devices=[]`, `gpus=[]`, runtime `runc` and `restarts=0`. Seven pods were +1/1 Running with 0 restarts and the gateway API CRD job Completed; the node had no taint. A +read-only live view (`diag/smoke4-live.sh.txt`, record `runs/smoke4-live.txt`) read the node +container every 10 s: running with `restarts=0` from 05:11:03Z to 05:11:33Z, then gone (the +`down`). Its one full read waited for `ready.sh`, which first passed between two of its polls, so +it made none. In `smoke3` the node restarted every ~16 s. The smoke sent no request and measured +nothing: as rule W1's smoke it is discarded and disclosed, and is not a hold of any row. After it, +no `k3dgpu-w-*` cluster or container and no process of the hold was left, the host's containers, +start times, restart counts and port codes read as before it, and the firewall's line counts read +as before the third load. + +**GPU isolation on smoke4's running cluster** (rule W8; `out/pods.txt`, `out/gpu-scan.txt` and +`out/node.txt`, read before `down`): none of the eight pods is a GPU plugin (coredns, +local-path-provisioner, metrics-server, the gateway API CRD job, the NRI balloons policy of the +P/E-core `hybrid` accelerator, and the three NFD pods); no pod mounts a hostPath under `/dev`, and +none asks for an nvidia.com/ or gpu.intel.com/ resource; the node's allocatable lists cpu, +ephemeral-storage, hugepages-1Gi, hugepages-2Mi, memory and pods only. + +**Resume procedure.** `RESUME.md` records the third load, 0.1.81 and `smoke4`. Step 1 and the +smoke pass, so the next step is step 6's first hold, `H52w`. H57wb's start gates (`/` >= 85 GB, +MemAvailable >= 100 GiB) were met at step 1; if they still are at step 6's row 7, the pack stops +there, as it says. + ### H5 amendment A1: stage-1 harness and definitions (2026-10-19) Written 2026-10-19T22:30-07:00, before any H5 measurement. It fixes what the @@ -10747,6 +10868,48 @@ whole stack, Qwen3Guard's 8 included, on a 32-CPU node; helm v3.22.1 with instances with this many pods; EAS #71 at teardown; and host memory taken by other tenants. +### F3 amendment R1: the locks and the run directory (2026-10-29) + +Written 2026-10-29, before any F3 step. Nothing in the F3 pre-registration above is +edited, and its prediction (0.35) stands. This amendment fixes how the run is held and +where its files go. It changes no step, ceiling or grade. + +- **One F3 row.** A second F3 row was drafted for this run, with other wording and a + prior of 0.5. It is not added. The row and section above were committed first, so they + are the only F3 pre-registration. +- **Locks.** The desktop holds (H55, H57dp, H59d) take `~/k3dgpu-bench.lock`, not + `/live.lock`. F3 takes `~/k3dgpu-bench.lock` first, then + `/live.lock`, each with `flock -o -w 36000`, so no child holds a lock + descriptor. That is the order H55 uses with `AD_EXTRA_LOCK`, so no wait cycle forms. + The 180 min cap counts from the moment both are held. +- **Node image.** The image step 0 builds gets a local tag, and step 1 names it with + `K3D_GPU_IMAGE`. The header records its image ID. +- **Run directory.** `K3D_GPU_STATE_DIR` is a new directory on disk for this run, so + `${STATE_DIR}` above is that directory. `KUBECONFIG` is unset, and every step uses the + cluster's own `${STATE_DIR}/kubeconfig-k3dgpu-f3-eas`. +- **ps samples.** Each 2 s `ps -eo args` sample is appended to one gzip file, and step 7 + counts through `zcat`. After step 8, the same counts are taken again over the same + files plus step 8's log and samples. That second scan is recorded, not graded. +- **When.** At the time of writing, `k3d-gpu eas` (#260) is not on `origin/main`, so F3 + waits for it, as the section says. #260's unscored wiring smoke runs first, on cluster + `k3dgpu-eas-smoke` inside the same two locks, and is recorded in #260. + +### F3 amendment R2: what the secret scan counts (2026-10-29) + +Written 2026-10-29, before any F3 step, after #260's second wiring smoke. It changes only +which values step 7 counts. No step, ceiling or other grade changes, and the prediction +stands. + +- **keycloak-admin-secret: the password key only.** The Secret has two keys, `username` + (5 bytes) and `password` (24 bytes). The username is an account name, not a secret. In + the smoke, a fixed-string count of it found 717 lines in the step logs, the ps samples + and the EAS logs, so counting it would grade any run REFUTED with no secret shown. Step + 7 counts the `password` key of `keycloak-admin-secret`, records that `username` was not + counted, and counts the other values as the section says. +- **Step 7's own log is not counted.** It is written while the scan runs and names the + Secrets only. Every other step log, the ps samples and every file under the EAS state + dir's `logs/` are counted as before. + ### H2 run (i9-13900T, 2026-10-16) ```text @@ -13353,6 +13516,301 @@ condition. `bench/h59/test_h59.py` adds a pooled CI above 1.2 with one hold at 0 (INCONCLUSIVE), one hold at exactly 1.0 (INCONCLUSIVE) and at 1.05 (SUPPORTED), and a CI below the bar with one slower hold (still REFUTED). +### H59 amendment R1: the runner and the host profiles (2026-10-29) + +Recorded 2026-10-29, before any H59, H59d or H59w measurement. This is the runner amendment the +pre-registration requires before the first H59-family hold. PR #182 (the llama.cpp backend of +`agent up`) is on main, merged 2026-10-29 18:25Z as `b8504e6b`. Mechanics only: no row's arms, +model files, statistic, quality gate, 1.2 bar, load rule, hold count, cap or prediction changes. +No wiring smoke has run. One runs before each row's first hold, scores nothing, and is disclosed +in that row's run section. + +**Files.** `bench/h59/`: + +- `run.py`: the arm table, the hold orders, the host profiles, each arm's render, stage and + check, crescent's load verdict and the prefix phase. +- `hold.sh`: one hold under the host's rules. `hold-run.sh`: the cluster, the P-core sampler and + the arms. `arm.sh`: one arm (render, readiness, records, phases or quality, teardown). +- `prep.sh`: the model files and the drafter, once per host. Also `k3d-shim.sh` and + `reproduce.sh`. +- `bench/wildct/row.sh` gains rows `h59w` and `h59w-prep`. + +These are imported from other rows, not copied: H54's GSM8K items, prompt and grader, its TTFT +prompt, void rule and `boot_ratio`; H57d's P-core sampler and load gate; H25's prefix prompts +(`bench/shared-prefix/probe.py`). Scoring is amendment S1's `bench/h59/h59.py`, unchanged. + +Tests: `bench/h59/test_run.py` and the `h59w` rows of `tests/wildct_hold_test.sh`, in CI. They +render both arms through the real `k3d-gpu agent manifest` against the fit fixtures, with a +kubectl stand-in that answers only for the hold's own context. They run `arm.sh` against a local +OpenAI-compatible server and score its records with `h59.py`. Four holds of a desktop row feed +the bootstrap-ratio scorer end to end, and the server's request log is checked against the +registered counts and settings (warmup, N=1, N=4, TTFT, prefix, GSM8K, the 900 s boot limit). A +crescent arm runs its N=16 phase and re-runs a void N=4 phase. + +**Arms as rendered.** Each arm is `scripts/k3d-gpu agent manifest --model ` at the runner's +commit, run in the hold's cluster. O is `--model google/gemma-4-26B-A4B-it`. L is `--model +google/gemma-4-26B-A4B-it-qat-q4_0-gguf` with `K3D_GPU_AGENT_DRAFT=mtp`. Both set +`K3D_GPU_AGENT_DEVICE=CPU`. The render also sets: + +- `K3D_GPU_CLUSTER` to the hold's cluster. Every node query of `agent manifest` names context + `k3d-${K3D_GPU_CLUSTER:-gpu}`. Without it a hold's own kubeconfig answers "context was not + found", and the arm renders with no P-core label and 1 CPU. That was H57dp's Od (PR #247); see + [H57d amendment R2](#h57d-amendment-r2-ods-render-names-the-holds-cluster-2026-10-29). +- `K3D_GPU_AGENT_REVISION` to the registered commit: O `4e5885c3`, the commit OVB's weights are + fetched at, for the fit's metadata read; L `d1c082be`. An unpinned render reads `@main` + metadata. On the desktop that is a stale cache entry, which the llama.cpp backend refuses + ("metadata names no commit"). For O the launcher also reads the requested base repo's format + at that commit (`agent_model_format`). The base repo has no such commit, so the format reads as + unknown and the render takes OVMS's path, as it does without the pin. +- `K3D_GPU_AGENT_MODEL_NAME=gemma`, the served name. +- `K3D_GPU_TUNE=off`. A host's own tuned profile is not a k3d-gpu default. + +No other agent variable reaches the render. It drops any `K3D_GPU_AGENT_*`, `K3D_GPU_LLAMA_*`, +`K3D_GPU_MODEL_OPT`, `K3D_GPU_MODEL_OPT_LEDGER`, `K3D_GPU_AUTOQUANT`, `K3D_GPU_FIT`, +`K3D_GPU_FIT_RUNG`, `K3D_GPU_FIT_FILE`, `K3D_GPU_CONTEXT`, `K3D_GPU_KUBECONFIG` or +`K3D_GPU_BALLOONS` set in the shell that starts a hold. The variables it passed are recorded +(`render-env.txt`). + +`run.py check` then reads the staged render. A render that fails it stops the arm before +`kubectl apply` (status failed), and the hold counts. It checks: + +- the images: O `openvino/model_server@sha256:a928903f...` (2026.4.1), L + `ghcr.io/ggml-org/llama.cpp:server-b11960@sha256:8052c13e...`; +- device CPU; no GPU resource; +- O's pull container names OVB, CPU, `text_generation` and no draft; +- L's `--model` is the GGUF at `d1c082be` and `--spec-draft-model` the drafter converted from + `95371415`, with `--spec-type draft-mtp`, `--jinja`, `--reasoning off` and numeric `-c`, `-np`, + `--threads` and `--threads-batch`; +- on the desktop and wildct, the P-core label and a 16-CPU request (L: `--threads 8`, + `--threads-batch 16`); on crescent, no core label. + +A later bump of either image pin in the launcher stops the holds until an amendment names it. +Offline renders of this commit, on the desktop fit fixture, give O the fit's `--max_num_seqs=4` +and `--cache_size=20`. They give L `-c 1310720 -np 5 -ngl 0 --threads 8 --threads-batch 16 +--load-mode mmap+mlock`. On a host the fit follows the host's free memory at render time. So each +arm's `fit.json` records what it ran with. `check.json` notes a fit of fewer than 4 sequences, +which makes the N=4 phase queue; that is reported, not gated. + +**Flags read at each pin.** This is the pre-registration's flag check. No flag is missing, so +none is dropped. + +- llama.cpp `b11960`, `common/arg.cpp`: `-m/--model` :3057, `-mu/--model-url` :3066, + `-a/--alias` :3033, `--host` :3343, `--port` :3359, `--jinja` :3727, `-rea/--reasoning` :3746, + `-c` :1650, `-np` :2556, `-ngl` :2811, `-t/--threads` :1528, `-tb/--threads-batch` :1538, + `-lm/--load-mode` :2703 (`mmap+mlock` is one of its values), `--spec-draft-model` :4322 and + `--spec-type` :4330. `draft-mtp` is a type in `common/speculative.cpp` :78. +- Defaults L relies on: the prompt cache is on (`common/common.h` :639, `cache_prompt = true`), + and the draft length is 3 (:331). +- OVMS `v2026.4.1`, `src/cli_parser.cpp`: `--task` :324, `--rest_port` :93, `--log_level` :117, + `--cache_dir` :152, `--pull` :236, `--source_model` :240, `--model_repository_path` :256, + `--model_name` :270, `--model_path` :274 and `--target_device` :314. +- OVMS `src/graph_export/graph_cli_parser.cpp`: `--max_num_seqs` :44, `--enable_prefix_caching` + :52 (default true, so prefix caching is on), `--cache_size` :60, `--reasoning_parser` :84 and + `--tool_parser` :88. This parser allows unrecognised options, so a misspelt pull argument would + be dropped without a word. This read rules that out. + +**Model files.** `prep.sh` runs once per host and is not a hold. + +- Downloads at the registered commits, outside the lock, into `${G}/models`: the GGUF repo's + `gemma-4-26B_q4_0-it.gguf` only, and OVB's snapshot. Each LFS file is checked against the Hub's + sha256 (`bench/wildct/verify_models.py`). +- The GGUF is also reachable as `gguf/google__gemma-4-26B-A4B-it-qat-q4_0-gguf@d1c082be.../`, + the path L's render serves it from. +- OVB's directory and `.ov_cache` are mode 777. OVMS's pull container runs as uid 5000 (H54 + amendment R2's directory mode). +- The drafter is #182's own conversion. `prep.sh` renders L with no cluster and takes the + conversion Job from that render: `full-b11960@sha256:7afc81bf...`, the pins + `transformers==5.21.0 huggingface-hub==2.3.0 tokenizers==0.23.3 safetensors==0.8.0`, + `convert_hf_to_gguf.py --outtype bf16`, then `llama-quantize` Q4_0. The offline render names + it Job `gguf-convert-b459c9c78d78`. `prep.sh` runs that container once with `docker run`, under + the host lock and `timeout -k 30 3600`. It keeps the Job's image, command, arguments, + environment and memory limit, mounts the models at `/models`, and runs as the campaign user, + not root. +- The Job's spec is kept as `prep-job.json`. A hold whose render carries another conversion Job + does not start the arm. +- The drafter's sha256 is recorded in each Reproduce header. It is a different build from H57d's + (`8eb21d5b...`, b11749, transformers 5.20.2), as amendment S1 notes that L already differs from + H57d's Ld-mtp. `${G}/weights.sha256` holds every file the arms read. + +**Staging.** `run.py stage` changes where an arm reads its files, nothing it serves. The k3d shim +binds `${G}/models` into the node at `/var/apb-models`, read-write, because O's pull container +writes `graph.pbtxt`. Weights are never copied into the node. + +- O: `bench/apb/stage.py ovms`, so the PVC becomes that hostPath. The pull container is kept. It + rewrites `graph.pbtxt` from the hold's own arguments and finds OVB's files with no `.git`, so it + downloads nothing. OVMS's compiled-model cache is emptied after each O arm (H54's teardown), so + no boot reads an earlier hold's cache. +- L: the PVC becomes the same hostPath, mounted read-only. The conversion ConfigMap and Job and + the `wait-gguf` init container are dropped; after `prep.sh` each would return at once. So is + `--model-url` with its value: it is llama-server's download source, and the GGUF is already at + `--model`. +- Image, every other argument and its order, requests, limits and labels are as rendered. The + tests compare the staged render with the raw one. + +**Holds.** `hold.sh HOST HOLD [REPEATS]`: + +- Holds 1-3 are performance holds in the orders (O, L), (L, O), (O, L). Hold 4 is the quality + hold, in (L, O). +- A spare 5 or 6 repeats hold REPEATS in its order. It may only repeat a hold that ran and whose + standing record (the hold, or spare 5 once spare 5 repeated it) left an arm not measured. + Spare 6 runs only after spare 5, since `h59.py` reads spare 6 over spare 5 for the same hold. + A spare writes the file `repeats`, which `h59.py` reads, only once it runs. +- A hold runs once. Once the floors pass, `hold.sh` writes the hold's `started` file, before + anything runs. A hold with a `started` file or an `rc` is refused, so a failed or interrupted + hold counts and only a spare repeats it. There is no hold number above 6. +- Before the lock, `hold.sh` refuses a host whose CPU class is not the row's, a node image not + pinned by digest, a `${G}/bin/k3d` that is not the shim, and missing model files. The CPU + class is the model number the optimizer matches a row by (`console/model_opt.py` + `class_tokens` of the first `model name`), compared exactly: an i7-13700K is not an i7-13700. +- The node image (`H59_IMAGE`) is the k3d-gpu release whose launcher is this commit's or later, + by digest. The row's first hold records it, and later holds must run the same image. +- A wiring smoke (`hold.sh HOST smoke`) runs hold 1's arms with one 16-token request and one + `chat_template_kwargs` chat each. It scores nothing and is disclosed. + +**Host profiles** (`run.py profile`): + +| | crescent (H59) | desktop (H59d) | wildct (H59w) | +|---|---|---|---| +| CPU checked | Xeon 6741P | i9-13900T | i7-13700 | +| Lock | `~/k3dgpu-bench.lock` | the desktop's (`LOCK`) | W1's (outer), then a private inner lock | +| Hold start | `load1` <= 12, no aiperf process (H50) | `load1` <= 24 | `load1` <= 12 (W1, and again inside) | +| Floors | `/` at most 97% used | 15 GB free on `/`, MemAvailable >= 20 GiB | as the desktop, inside W6's 15 + 40 GB | +| Cap, arms end | 2400 s, 2370 s (H54) | 4500 s, 4470 s (H57d) | 4500 s, 4470 s (H57w), inside W1's 4600 s | +| Cluster | `k3dgpu-g-h59-`, `amx`, C1 | `k3dgpu-d-h59-`, `hybrid`, C1 | `k3dgpu-w-h59-`, `auto,-nvidia,-intel`, W3 | +| CPUs | no balloon (half the node) | P-core balloon, 16 CPUs | as the desktop | +| Phases | N=1, N=4, N=16, TTFT, prefix | N=1, N=4, TTFT, prefix | as the desktop | +| Load rule | H54's void rule, `run.py gate` | H57d's gate, `h57d.py sample` | as the desktop | + +wildct runs each hold as `bench/wildct/hold.sh H59w- 4600 40 bench/wildct/row.sh h59w +[]`. That gives it W1's lock, the GPU opt-out, W3, W4's RAPL reader, W5's sampler and W6's +15 + 40 GB start floor. `prep.sh` runs as `row.sh h59w-prep`, outside a hold. + +**Phases.** As registered, in `arm.sh`: + +- the 60 s idle window (RAPL), then 3 warmup requests; +- N=1 (8 requests), N=4 (16 at concurrency 4), N=16 (32 at 16, crescent only), and TTFT (4 at + N=1 on H54's 1,000-token prompt, `max_tokens` 16). Each runs `bench/llm_load.py + --unique-prompts`, `max_tokens` 256, `ignore_eos`, temperature 0, under `bench/energy.sh`; +- prefix reuse: H25's phase S without its control agent (`probe.py` `gen` and `request`). Agents + a1-a6 run in sequence. Each sends the byte-identical system text and 12 tool schemas plus its + own 96-token message, `max_tokens` 8, thinking off, streamed. The seed is 5900 plus the hold's + number. The score is the median first-chunk TTFT of agents 2-6 (`prefix.json`). It records + `usage.prompt_tokens`, which Gemma's tokenizer puts near H25's 1,536, not at it; +- quality: H54's GSM8K 0-49 runner. + +An arm is measured when its N=4 phase completed a request. Otherwise it is failed: the server +stopped answering after readiness (H54 amendment R3). In the quality hold an arm is measured when +GSM8K recorded an item that did not error. The records use H57d's layout, which `h59.py` reads. +Each arm adds `cpus.txt` (the server's command line, cgroup CPU set, `Cpus_allowed_list`, ISA and +threads' affinity), `fit.json`, `check.json`, `deploy.yaml` and `agent.yaml`, and O adds the +`graph.pbtxt` it served. + +**Load.** On the desktop and wildct, H57d's sampler runs beside each hold, and the row is scored +with `h59.py score RESULTS --load h57d`. On crescent, H54's rule (amendment R4) runs in the hold. +Each phase is checked against the serving samples of the arm's idle window, and a void phase is +re-run once after a fresh idle window. `run.py gate RESULTS` writes the verdict that `h59.py +score RESULTS --load GATE.json` reads. The load is unmatched when an N=4 phase of a performance +hold (a spare in place of the hold it repeats) was void on both tries or carries no check. + +**The engine rule as merged.** #182 merged as `b8504e6b`. There `console/model_opt.py optimize +--engine-from-ledger` is the rule amendment S1 describes at #182's head `62ac8749`: + +- With `K3D_GPU_AGENT_BACKEND` unset and no variant named, an H59-family row SUPPORTED for the + host's CPU class makes llama.cpp the CPU engine, when llama.cpp has an int4 GGUF of the model. + That arm drafts with the QAT MTP assistant. +- A REFUTED row for the class keeps OVMS and wins over a SUPPORTED one. PENDING and INCONCLUSIVE + rows pick nothing. +- `agent up` keeps OVMS when `K3D_GPU_AGENT_DRAFT` names an OVMS draft device. + +**Reported for the owner, not acted on: what the desktop load gate reads.** H59d's and H59w's +gate (H57d's, amendment S1) compares the arms' mean foreign P-core load over the N=4 phases. That is the +busy time of CPUs 0-15 minus the k3d node's own CPU, from H57d's sampler. On the desktop's records +(`bench/h57d/results/2026-10-21` and `2026-10-29`) this reading moves with the arm, not with the +host. Foreign P-core load in CPU-s/s, per hold, +over each arm's 60 s idle window (server up, no request) and over its N=4 phase: + +| Records | Ld idle, N=4 | Ld-mtp idle, N=4 | Ld-mtp-u idle, N=4 | Od idle, N=4 | +|---|---|---|---|---| +| H57d hold 1 | 7.20, 8.21 | 9.32, 6.60 | | 6.40, 6.48 | +| H57d hold 2 | 6.25, 6.28 | 6.79, 6.95 | | 6.06, 1.89 | +| H57d hold 3 | 3.06, 2.52 | 3.47, 3.18 | | 2.74, 0.53 | +| H57dp hold 1 | | 3.19, 2.58 | 3.07, 3.37 | 3.67, 0.37 | +| H57dp hold 2 | | 3.87, 3.50 | 3.58, 3.57 | 3.10, 1.17 | + +- In their idle windows the arms read alike. Outside H57d's busiest hold 1, the llama.cpp arms + keep about their idle reading under load, pinned (Ld, Ld-mtp) or not (Ld-mtp-u), and Od's + falls. +- Where Od's reading falls, its own node CPU over N=4 (7.7-8.0 CPU-s/s) is close to the whole + P-core busy time (8.3-9.6). So during Od's phases the host's other processes ran mostly off the + P-cores. During the llama.cpp arms' phases, which decode on 8 threads (`-t 8`), they kept their + share of the P-cores. Why the scheduler places them so (for instance, on idle hyper-thread + siblings beside one-thread-per-core decoding) is not measured here. +- So the gate, read over the phases, compares a quantity the arm itself sets. That is the trap H54 + amendment R4 named for crescent's void rule: a post-treatment variable. +- H59's L runs #182's `--threads 8 --threads-batch 16`, with no pinning, and decodes on 8 threads. + The same pattern is expected. An INCONCLUSIVE (load unmatched) H59d or H59w would then say + little about the host's load. +- Read over the arms' idle windows instead, the arms match. H57d's three arms read 5.50, 6.53 and + 5.06 (spread 1.46 <= 2.00); H59's pair on those records, Ld-mtp and Od, reads 6.53 and 5.06. + H57dp's three arms read 3.53, 3.33 and 3.38 (spread 0.21). Over the N=4 phases H57d's pair reads + 5.58 and 2.96, spread 2.61 > 2.00 (amendment S1's table). + +Changing H59d's and H59w's gate, for instance to read the idle windows as H54 amendment R4 did, is +a protocol change and the owner's decision. It would need a dated amendment before the first H59d +or H59w hold. This amendment does not make it. The runner records both readings: the idle window +is a phase in each arm's `phases.jsonl`, beside the sampler's `load.jsonl`. + +### H59 amendment R2: H59d's and H59w's load gate reads each arm's idle window (2026-10-31) + +Recorded 2026-10-31, before any H59d or H59w hold or wiring smoke. The owner made the decision on +2026-10-29, shortly after 23:00 PDT, choosing "Gate on idle window" after amendment R1 reported +what the desktop's load gate reads. It governs H59d and H59w from their first hold. No row's +arms, model files, statistic, quality gate, 1.2 bar, hold count, cap, prior or prediction +changes. H59 on crescent keeps H54's void rule (amendment R4), unchanged. + +**What prompted it.** H59d's and H59w's gate (H57d's, amendment S1) compares the arms' mean +foreign P-core load over the N=4 phases. On the desktop's records that reading moves with the +arm, not with the host: a post-treatment variable, the trap H54 amendment R4 named for crescent's +void rule. Amendment R1's table gives the per-hold readings. Per arm, over the records' holds: + +| Records (`bench/h57d/results/`) | Arms | Over the N=4 phases | Over the 60 s idle windows | +|---|---|---|---| +| H57d, `2026-10-21` | Ld-mtp, Od (H59's L and O) | 5.58, 2.96: spread 2.61 > 2.00, unmatched | 6.53, 5.06: spread 1.46 <= 2.00, matched | +| H57dp, `2026-10-29` | Ld-mtp, Ld-mtp-u, Od | 3.04, 3.47, 0.77: spread 2.70 > 2.00, unmatched | 3.53, 3.33, 3.38: spread 0.21 <= 2.00, matched | + +Readings are in CPU-s/s; each spread is taken over the unrounded means. The limit is H57d's, +max(2.00, 25% of the mean of the means) CPU-s/s, 2.00 in every case here. +`bench/h59/test_run.py` recomputes these four spreads from the committed records. H57d's and +H57dp's own verdicts are not re-scored. + +**Rule.** As H54 amendment R4 did on crescent, the load gate reads each arm's idle window. + +- H59d's and H59w's load gate is H57d's (`h57d.py` `load_gate`, unchanged), applied to each arm's + mean foreign P-core load over its 60 s idle windows in performance holds 1-3. A spare stands in + for the hold it repeats, and only measured arms count, as in amendment S1. The idle window is + the `idle` phase of the arm's `phases.jsonl`: the arm's server is up, before the warmup, with no + request. Foreign load is H57d's: P-core busy (CPUs 0-15) minus the k3d node's own CPU, at least + 0, from H57d's sampler (`load.jsonl`) beside the hold. +- The reading over the N=4 phases (amendment S1's) is still computed and recorded beside the + verdict (`n4` in the gate's record, marked `"gates": false`), and the run section reports it. It + no longer gates. +- An arm with no idle-window reading leaves fewer than two arms with samples. H57d's rule makes + that unmatched, unchanged. An unmatched load still gives INCONCLUSIVE, at the same step of the + decision order. +- Scoring: `bench/h59/run.py idle-gate RESULTS > gate.json`, then `bench/h59/h59.py score RESULTS + --load gate.json`. The scorer's decision is unchanged. Its `--load h57d` still reads the N=4 + phases, so for H59d and H59w it gives the recorded reading, not the verdict. +- The holds do not change. `arm.sh` already records the idle window as a phase (amendment R1). + The desktop's and wildct's profiles now name `load_rule` `h57d-idle`; `arm.sh` branches only on + crescent's `h54-void`. wildct's own rules (W1-W6, the hold-start `load1` <= 12) are unchanged. + +**Files.** `bench/h59/run.py`: `idle_gate`, the `idle-gate` command and the profiles' +`load_rule`. `bench/h59/test_run.py`: class `IdleGate` (idle windows gate while N=4 is recorded +only; idle windows apart are unmatched even when N=4 matches; a spare, an unmeasured arm and a +missing window; the CLI; the spreads above from the committed records). Its end-to-end desktop +row is now scored through `run.py idle-gate`. The `bench/h59/h59.py` docstring names the change; +its code does not change. + ## Scope corrections Limits on what a SUPPORTED verdict may be quoted as saying. diff --git a/README.md b/README.md index 8e7aad3f..9231b017 100644 --- a/README.md +++ b/README.md @@ -1521,7 +1521,28 @@ That is placement, not isolation: package power, LLC and DRAM bandwidth stay sha and E-core load still slowed CPU-served prefill on the P-cores [ledger:H3]. `up` waits for a warm sandbox, then runs `status`, which reads each sandbox's `Cpus_allowed_list` and fails if it is not inside the host's E-cores. -`k3d-gpu sandbox down` removes it all. +`k3d-gpu sandbox down` removes the profile, then the namespace `agent-sandbox-system`, +the agent-sandbox CRDs and the controller, unless something else uses them. + +Other installers put agent-sandbox in the same namespace: Intel's Enterprise Agent +Toolkit installs v0.5.0 into `agent-sandbox-system` with its own warm pool and router. +k3d-gpu counts the controller as its own when the Deployment carries +`app.kubernetes.io/managed-by=k3d-gpu` (`up` labels the Deployment, the namespace and +the CRDs after it installs them) or runs the controller image k3d-gpu pins, with no +other installer's `managed-by` label or Helm release on it. + +- `up` stops before applying anything when another installer's controller is there, + or a CRD carries another installer's `managed-by` label or Helm release, and names + the image or the CRDs. +- `up --adopt` keeps that controller and applies only the E-core profile. The + SandboxTemplate and SandboxWarmPool CRDs must serve `extensions.agents.x-k8s.io/v1beta1`; + v0.5.0's do. +- `down` keeps the namespace, the CRDs and the controller when the controller is + another installer's, when a CRD carries another installer's label or Helm release, + when a Sandbox, SandboxClaim, SandboxTemplate or SandboxWarmPool exists outside + namespace `agent-sandbox`, or when a pod other than the controller's runs in + `agent-sandbox-system`. It prints what it kept and why, and exits 0. Deleting a CRD + deletes every object of that kind in every namespace. ```bash k3d-gpu sandbox up @@ -1715,7 +1736,9 @@ tests/render_test.sh CI fails when a committed manifest differs from its pinned render. The daily build-and-push workflow runs `--latest` for every component and opens one PR per component (branch `autobump/`); merging it changes `share/`, so auto-release cuts a -launcher release and the node image is rebuilt for it. +launcher release and the node image is rebuilt for it. GITHUB_TOKEN opens these PRs, so +their own pull_request CI runs wait for approval; `hack/bot-ci-dispatch` runs `ci.yml` on +the branch by workflow_dispatch instead, and that run is the PR's CI. --- diff --git a/bench/h57d/arm.sh b/bench/h57d/arm.sh index 6f16cf1c..5bdfbdd8 100644 --- a/bench/h57d/arm.sh +++ b/bench/h57d/arm.sh @@ -49,7 +49,11 @@ stamp "start ${MODE} (${left} s of the hold left)" M=${OUT}/deploy.yaml case ${DEPLOY} in k3d-gpu) - K3D_GPU_AGENT_DEVICE=CPU K3D_GPU_AGENT_MODEL=$(field model) K3D_GPU_AGENT_MODEL_NAME=${MODEL} K3D_GPU_AGENT_DRAFT=off \ + # K3D_GPU_CLUSTER: `agent manifest` reads the node's hybrid label and allocatable CPU + # through context k3d-${K3D_GPU_CLUSTER}; unset, that is k3d-gpu, which the hold's + # kubeconfig does not hold, and Od renders with no P-core label and 1 CPU (H57d + # amendment R2). + K3D_GPU_CLUSTER=${CLUSTER} K3D_GPU_AGENT_DEVICE=CPU K3D_GPU_AGENT_MODEL=$(field model) K3D_GPU_AGENT_MODEL_NAME=${MODEL} K3D_GPU_AGENT_DRAFT=off \ K3D_GPU_AUTOQUANT=off "${REPO}/scripts/k3d-gpu" agent manifest > "${OUT}/agent.yaml" 2> "${OUT}/agent.err" \ && ${PY} "${REPO}/bench/apb/stage.py" ovms "${OUT}/agent.yaml" "${OUT}/staged.yaml" \ && ${PY} "${D}/h57d.py" od-ro "${OUT}/staged.yaml" "${M}" ;; diff --git a/bench/h57d/test_h57d.py b/bench/h57d/test_h57d.py index ee4dd662..96e091c9 100644 --- a/bench/h57d/test_h57d.py +++ b/bench/h57d/test_h57d.py @@ -9,6 +9,7 @@ import subprocess import sys import tempfile +import time import unittest HERE = os.path.dirname(os.path.abspath(__file__)) @@ -644,5 +645,74 @@ def test_engine_flags_block(self): self.assertIn(" llamacpp: " + h57d.LLAMACPP, lines) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) +FIX = os.path.join(ROOT, "tests", "fixtures", "fit") +# kubectl stand-in: `k3d-gpu` names --context on every call; a hold's kubeconfig holds only the +# hold's cluster, so any other context reads "context was not found". apply fails, which stops +# arm.sh right after the render. +KUBECTL = r'''#!/usr/bin/env python3 +import os, sys +a = sys.argv[1:] +ctx = None +while a and a[0] == "--context": + ctx, a = a[1], a[2:] +if ctx is not None and ctx != "k3d-" + os.environ["STUB_CLUSTER"]: + sys.exit(f"error: context was not found for specified context: {ctx}") +s = " ".join(a) +if s == "get nodes -l k3d-gpu.io/hybrid-cpu=true -o name": + print("node/k3d-x-server-0") +elif "allocatable" in s: + print("cpu 32") +elif a[:1] == ["apply"]: + sys.exit("apply refused by the test") +''' + + +@unittest.skipUnless(HAVE_YAML, "PyYAML") +class OdRendersInTheHoldsCluster(unittest.TestCase): + """H57d amendment R2: arm.sh renders Od with K3D_GPU_CLUSTER set to the hold's cluster, so + `agent manifest` reads the hold's hybrid node: the P-core label and the 16 P-core CPUs (H57dp's + deviation was a render through context k3d-gpu: no label, 1 CPU).""" + + def test_od_render_has_the_pcore_balloon(self): + with tempfile.TemporaryDirectory() as d: + b = os.path.join(d, "bin") + os.makedirs(b) + with open(os.path.join(b, "kubectl"), "w") as f: + f.write(KUBECTL) + os.chmod(os.path.join(b, "kubectl"), 0o755) + cache = os.path.join(d, "cache") + os.makedirs(cache) + for n in os.listdir(os.path.join(FIX, "models")): + src = os.path.join(FIX, "models", n) + os.symlink(src, os.path.join(cache, f"{n}@main")) + sysd = os.path.join(d, "root", "sys", "devices") + for name, cpus in (("cpu_core", "0-15"), ("cpu_atom", "16-31")): + os.makedirs(os.path.join(sysd, name)) + with open(os.path.join(sysd, name, "cpus"), "w") as f: + f.write(cpus + "\n") + open(os.path.join(d, "kubeconfig"), "w").close() + env = {"PATH": b + ":/usr/bin:/bin", "HOME": d, "REPO": ROOT, "CLUSTER": "k3dgpu-d-h57d-1", + "STUB_CLUSTER": "k3dgpu-d-h57d-1", "KUBECONFIG": os.path.join(d, "kubeconfig"), + "DEADLINE": str(int(time.time()) + 5000), "MODE": "perf", + "K3D_GPU_SYSROOT": os.path.join(d, "root"), "K3D_GPU_FIT_CACHE": cache, + "K3D_GPU_FIT_HOST": os.path.join(FIX, "hosts", "desktop-i9-nogpu.json"), + "K3D_GPU_HF_FIXTURES": os.path.join(ROOT, "tests", "fixtures", "hf"), + "XDG_STATE_HOME": os.path.join(d, "state"), "XDG_CACHE_HOME": os.path.join(d, "xdg")} + out = os.path.join(d, "Od") + r = subprocess.run(["bash", os.path.join(HERE, "arm.sh"), "Od", out], env=env, + capture_output=True, text=True, timeout=300) + with open(os.path.join(out, "status.json")) as f: + self.assertIn("apply failed", json.load(f)["reason"], r.stdout + r.stderr) + with open(os.path.join(out, "agent.err")) as f: + err = f.read() + with open(os.path.join(out, "deploy.yaml")) as f: + docs = [x for x in yaml.safe_load_all(f) if x] + dep = [x for x in docs if x["kind"] == "Deployment"][0] + tpl = dep["spec"]["template"] + self.assertEqual(tpl["metadata"]["labels"].get("k3d-gpu.io/cores"), "performance", err) + self.assertEqual(str(tpl["spec"]["containers"][0]["resources"]["requests"]["cpu"]), "16", err) + + if __name__ == "__main__": unittest.main() diff --git a/bench/h59/arm.sh b/bench/h59/arm.sh new file mode 100644 index 00000000..d993a779 --- /dev/null +++ b/bench/h59/arm.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +# arm.sh ARM OUT: one H59-family arm in the hold's cluster (EXPERIMENTS.md "H59 pre-registration" +# and "H59 amendment R1"): render it with `k3d-gpu agent manifest --model` in the hold's cluster, +# stage and check it (run.py render), wait for the model list and a 16-token chat, record the +# server's image, command line, CPUs and fit, run the hold's phases (MODE=perf: 60 s idle, 3 +# warmup requests, N=1, N=4, N=16 on crescent, TTFT, prefix reuse; MODE=quality: GSM8K items +# 0-49), then delete the arm. +# REPO this repository; G the campaign dir (models under G/models) +# CLUSTER the hold's cluster; KUBECONFIG its scratch kubeconfig; H59_PROFILE the host +# DEADLINE epoch second the hold's arms must end by; MODE perf | quality +# HOLDNUM the hold's number (the prefix phase's seed); SERVING crescent's serving.jsonl +# H59_PORT local port (default random); H59_IDLE_S idle window (default 60) +# status.json: measured | not_deliverable | not_reached | failed | smoke, with the reason. +set -uo pipefail +ARM=$1 OUT=$2 +mkdir -p "${OUT}" +D=${REPO:?}/bench/h59 +H=${REPO}/bench/h54 +PY=python3 +RUN=${D}/run.py +LOAD=${REPO}/bench/llm_load.py +ENERGY=${REPO}/bench/energy.sh +BOOT=900 +IDLE=${H59_IDLE_S:-60} +now() { date +%s; } +stamp() { echo "$(date -u +%FT%TZ) ${ARM} $*" | tee -a "${OUT}/arm.log"; } +status() { ${PY} -c 'import json,sys; json.dump({"status": sys.argv[1], "reason": sys.argv[2], "at": sys.argv[3]}, open(sys.argv[4], "w"))' \ + "$1" "$2" "$(date -u +%FT%TZ)" "${OUT}/status.json"; stamp "status $1: $2"; } +field() { ${PY} "${RUN}" field "${ARM}" "$1"; } +pfield() { ${PY} "${RUN}" profile "${H59_PROFILE:?}" | sed -n "s/^$1=//p"; } +NODE=k3d-${CLUSTER:?}-server-0 +NSP=k3d-gpu-agent SVC=$(field svc) SPORT=$(field port) API=$(field api) +PHASES=$(pfield phases) RULE=$(pfield load_rule) +MODEL=gemma +PORT=${H59_PORT:-$(( 19000 + RANDOM % 1000 ))} +pf="" +pf_loop() { # kubectl port-forward can drop on a long phase: respawn it + trap 'kill "${k}" 2>/dev/null; exit 0' TERM + while :; do + kubectl -n "${NSP}" port-forward "svc/${SVC}" "${PORT}:${SPORT}" >> "${OUT}/pf.log" 2>&1 & + k=$! + wait "${k}" + echo "port-forward exited $(date -u +%FT%TZ)" >> "${OUT}/pf.log" + sleep 1 + done +} +cleanup_pf() { [ -z "${pf}" ] || kill "${pf}" 2>/dev/null; pf=""; } +trap cleanup_pf EXIT + +need=900 +[ "${MODE:?}" = quality ] && need=600 +left=$(( DEADLINE - $(now) )) +if [ "${left}" -lt "${need}" ]; then status not_reached "${left} s of the hold left (< ${need})"; exit 0; fi +stamp "start ${MODE} (${left} s of the hold left)" + +# ---- render, stage, check (run.py render): agent.yaml, deploy.yaml, check.json, fit.json -------- +M=${OUT}/deploy.yaml +if ! why=$(${PY} "${RUN}" render "${ARM}" "${OUT}" 2>&1); then + status failed "$(tail -1 <<< "${why}")" + exit 1 +fi +kubectl apply -f "${M}" > "${OUT}/apply.log" 2>&1 || { status failed "apply failed: $(tail -1 "${OUT}/apply.log")"; exit 1; } + +# ---- readiness: the model list and a 16-token chat ------------------------------------------ +t_apply=$(now) +limit=$(( BOOT < DEADLINE - t_apply - 300 ? BOOT : DEADLINE - t_apply - 300 )) +base="http://127.0.0.1:${PORT}${API}" +ready="" +while [ $(( $(now) - t_apply )) -lt "${limit}" ]; do + if [ -z "${pf}" ] || ! kill -0 "${pf}" 2>/dev/null; then + pf="" + if kubectl -n "${NSP}" get pods -l "app=${SVC}" -o jsonpath='{.items[*].status.phase}' 2>/dev/null | grep -q Running; then + pf_loop & + pf=$! + sleep 3 + fi + fi + if [ -n "${pf}" ] && curl -fsS --max-time 10 "${base}/models" > "${OUT}/models.json" 2>/dev/null \ + && curl -fsS --max-time 120 "${base}/chat/completions" -H 'Content-Type: application/json' \ + -d "{\"model\":\"${MODEL}\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"Say hello.\"}]}" \ + > "${OUT}/hello.json" 2>/dev/null; then + ready=1 + break + fi + sleep 10 +done +t_ready=$(now) +echo "{\"apply\": ${t_apply}, \"ready\": ${t_ready}, \"boot_s\": $(( t_ready - t_apply )), \"limit_s\": ${limit}}" > "${OUT}/boot.json" +kubectl get pods -A -o wide > "${OUT}/pods.txt" 2>&1 +pod=$(kubectl -n "${NSP}" get pods -l "app=${SVC}" -o jsonpath='{.items[0].metadata.name}' 2>/dev/null) +ctr=$(kubectl -n "${NSP}" get pod "${pod}" -o jsonpath='{.spec.containers[0].name}' 2>/dev/null) +# cpu_usec: the k3d node container's cgroup CPU time, or nothing when it cannot be read. +cpu_usec() { + local id + id=$(docker inspect -f '{{.Id}}' "${NODE}" 2>/dev/null) || return 1 + awk '/^usage_usec/{print $2}' "/sys/fs/cgroup/system.slice/docker-${id}.scope/cpu.stat" 2>/dev/null +} +teardown() { + cleanup_pf + kubectl -n "${NSP}" logs "${pod}" --all-containers > "${OUT}/server.log" 2>&1 + kubectl -n "${NSP}" describe pod "${pod}" > "${OUT}/describe.txt" 2>&1 + kubectl delete -f "${M}" --wait=true --timeout=300s > "${OUT}/delete.log" 2>&1 + # OVMS's compiled-model cache leaves with the arm, so no later boot reads it (H54's teardown). + [ "${ARM}" != O ] || docker exec "${NODE}" sh -c 'rm -rf /var/apb-models/.ov_cache/*' >> "${OUT}/delete.log" 2>&1 + # The node's CPU reads idle (< 1 CPU-s/s over 10 s) before the next arm, at most 180 s; a + # cgroup that cannot be read ends the wait (recorded). + local a b + for _ in $(seq 18); do + a=$(cpu_usec) && [ -n "${a}" ] || { echo "idle wait: node cgroup not readable" >> "${OUT}/delete.log"; break; } + sleep 10 + b=$(cpu_usec) && [ -n "${b}" ] || break + [ $(( (b - a) / 10000000 )) -lt 1 ] && break + done +} +if [ -z "${ready}" ]; then + if [ "${limit}" -lt "${BOOT}" ]; then status not_reached "not ready within the ${limit} s the hold left (boot limit ${BOOT} s)" + else status not_deliverable "no model list and 16-token chat within ${BOOT} s"; fi + teardown + exit 0 +fi +stamp "ready after $(( t_ready - t_apply )) s" +kubectl -n "${NSP}" get pod "${pod}" -o jsonpath='{.spec.containers[0].image} {.status.containerStatuses[0].imageID}' > "${OUT}/image.txt" +kubectl -n "${NSP}" get pod "${pod}" -o json > "${OUT}/pod.json" +# The server's command line, the CPU set it got (the P-core balloon on a hybrid host), the ISA it +# sees and its threads' affinity. +kubectl -n "${NSP}" exec "${pod}" -c "${ctr}" -- sh -c \ + 'echo "cmdline $(tr "\0" " " < /proc/1/cmdline)"; echo "cpuset $(cat /sys/fs/cgroup/cpuset.cpus.effective)"; grep Cpus_allowed_list /proc/1/status; echo "avx512f $(grep -c avx512f /proc/cpuinfo) amx $(grep -c amx_tile /proc/cpuinfo) avx_vnni $(grep -c avx_vnni /proc/cpuinfo) nproc $(nproc)"; echo "server threads by affinity:"; for t in /proc/1/task/*; do grep -s Cpus_allowed_list "${t}/status" | cut -f2; done | sort | uniq -c' > "${OUT}/cpus.txt" 2>&1 +# O: the graph.pbtxt the --pull init container wrote from this hold's arguments. +[ "${ARM}" != O ] || kubectl -n "${NSP}" exec "${pod}" -c "${ctr}" -- cat "/models/OpenVINO/gemma-4-26b-a4b-it-int4-ov/graph.pbtxt" > "${OUT}/graph.pbtxt" 2>&1 + +if [ -n "${SMOKE:-}" ]; then + # Wiring smoke (discarded, disclosed): one streamed 16-token request with ignore_eos, one chat + # with chat_template_kwargs and the prefix phase's first agent, then the arm is deleted. + ${PY} "${LOAD}" --base-url "${base}" --model "${MODEL}" --requests 1 --concurrency 1 --warmup 0 --max-tokens 16 \ + --ignore-eos --unique-prompts --out "${OUT}/smoke-load.json" >> "${OUT}/arm.log" 2>&1 + curl -sS --max-time 300 "${base}/chat/completions" -H 'Content-Type: application/json' \ + -d "{\"model\":\"${MODEL}\",\"max_tokens\":16,\"chat_template_kwargs\":{\"enable_thinking\":false},\"messages\":[{\"role\":\"user\",\"content\":\"Say hello.\"}]}" \ + > "${OUT}/smoke-kwargs.json" 2>&1 + status smoke "ready; wiring checks in smoke-*.json" + teardown + exit 0 +fi + +# ---- phases ---------------------------------------------------------------------------------- +W0="" W1="" +idle() { # idle [SUFFIX]: the 60 s idle window (RAPL); on crescent also the void rule's CPU window + local t0 + t0=$(date +%s.%N) + bash "${ENERGY}" --zone intel-rapl:0 --interval 5 --json "${OUT}/energy-idle${1:+-$1}.json" -- sleep "${IDLE}" 2>> "${OUT}/arm.log" + W0=${t0} W1=$(date +%s.%N) + echo "{\"phase\": \"idle${1:+-$1}\", \"t0\": ${W0}, \"t1\": ${W1}}" >> "${OUT}/phases.jsonl" +} +load() { # load NAME REQUESTS CONCURRENCY MAX_TOKENS [extra llm_load args] + local name=$1 n=$2 c=$3 mt=$4 + shift 4 + bash "${ENERGY}" --zone intel-rapl:0 --interval 5 --json "${OUT}/energy-${name}.json" -- \ + ${PY} "${LOAD}" --base-url "${base}" --model "${MODEL}" --requests "${n}" --concurrency "${c}" --warmup 0 \ + --max-tokens "${mt}" --temperature 0 --unique-prompts --timeout 1800 --label "H59 ${ARM} ${name}" \ + --out "${OUT}/load-${name}.json" "$@" 2>> "${OUT}/arm.log" +} +phase() { # phase NAME REQUESTS CONCURRENCY MAX_TOKENS [extra]: run and record; on crescent (H54's + # rule, amendment R4) a void phase is re-run once after a fresh idle window + local name=$1 t0 t1 v try + for try in 1 2; do + t0=$(date +%s.%N) + load "$@" + t1=$(date +%s.%N) + if [ "${RULE}" != h54-void ]; then + echo "{\"phase\": \"${name}\", \"t0\": ${t0}, \"t1\": ${t1}}" >> "${OUT}/phases.jsonl" + return + fi + v=$(${PY} "${H}/h54.py" void "${SERVING:?}" "${t0}" "${t1}" "${W0}" "${W1}") + echo "{\"phase\": \"${name}\", \"try\": ${try}, \"t0\": ${t0}, \"t1\": ${t1}, \"w0\": ${W0}, \"w1\": ${W1}, \"check\": ${v}}" >> "${OUT}/phases.jsonl" + case ${v} in *'"void": true'*) [ "${try}" = 1 ] && { mv "${OUT}/load-${name}.json" "${OUT}/load-${name}-void1.json"; idle "${name}"; continue; } ;; esac + return + done +} +has() { [[ ",${PHASES}," == *",$1,"* ]]; } +if [ "${MODE}" = perf ]; then + idle + load warmup 3 1 256 --ignore-eos + phase n1 8 1 256 --ignore-eos + phase n4 16 4 256 --ignore-eos + if has n16; then phase n16 32 16 256 --ignore-eos; fi + phase ttft 4 1 16 --prompt "$(cat "${H}/ttft-prompt.txt")" + t0=$(date +%s.%N) + ${PY} "${RUN}" prefix "${base}" "${MODEL}" "$(( 5900 + ${HOLDNUM:-0} ))" "${OUT}/prefix.json" >> "${OUT}/arm.log" 2>&1 + echo "{\"phase\": \"prefix\", \"t0\": ${t0}, \"t1\": $(date +%s.%N)}" >> "${OUT}/phases.jsonl" + # Measured when the N=4 phase completed a request (h59.py scores the completed ones); a server + # that stopped answering after readiness is failed (H54 amendment R3). + if ${PY} -c 'import json,sys; s=json.load(open(sys.argv[1]))["phases"][0]["samples"]; sys.exit(not any(x.get("ok") for x in s))' \ + "${OUT}/load-n4.json" 2>/dev/null; then status measured "perf phases recorded" + else status failed "server stopped answering: the N=4 phase completed no request"; fi +else + t0=$(date +%s.%N) + ${PY} "${H}/h54.py" gsm8k "${base}" "${MODEL}" "${H}/gsm8k-test-0-199.jsonl" "${OUT}/quality.json" 0-49 >> "${OUT}/arm.log" 2>&1 + echo "{\"phase\": \"quality\", \"t0\": ${t0}, \"t1\": $(date +%s.%N)}" >> "${OUT}/phases.jsonl" + if ${PY} -c 'import json,sys; q=json.load(open(sys.argv[1])); sys.exit(not (q.get("n") and q.get("errors", 0) < q["n"]))' \ + "${OUT}/quality.json" 2>/dev/null; then status measured "quality recorded" + else status failed "quality not recorded, or every item errored"; fi +fi +teardown +exit 0 diff --git a/bench/h59/h59.py b/bench/h59/h59.py index b8c6b6b6..77cad689 100644 --- a/bench/h59/h59.py +++ b/bench/h59/h59.py @@ -9,10 +9,12 @@ tok/s over the pooled N=4 requests of performance holds 1-3, its 95% bootstrap CI (H54's boot_ratio: 10,000 resamples of each arm, seed 1), the per-hold N=4 ratios (SUPPORTED needs L faster than O in each performance hold, amendment S2), the quality gate (L's GSM8K items -correct >= O's - 2) and the host's load rule. --load h57d applies H57d's load gate (H59d's rule, and -H59w's through H57w) to the per-arm mean foreign P-core load over the N=4 phases, read from -the H57d sampler's records; --load GATE.json reads {"matched": bool, "reason": str} that a -host runner wrote under its own rule (H59 on the Xeon: H54's). --arms maps the arm names L and +correct >= O's - 2) and the host's load rule. --load h57d applies H57d's load gate to the +per-arm mean foreign P-core load over the N=4 phases, read from the H57d sampler's records +(amendment S1's reading; since amendment R2 it is recorded, not gated, for H59d and H59w); +--load GATE.json reads {"matched": bool, "reason": str} that a host runner wrote under its own +rule (H59 on the Xeon: H54's, `run.py gate`; H59d and H59w: H57d's gate over each arm's idle +window, `run.py idle-gate`, amendment R2). --arms maps the arm names L and O to the record directories' names (default L=L,O=O). Records, in H57d's layout: RESULTS_DIR/holdK/ARM/status.json for K = 1-4 (1-3 performance, diff --git a/bench/h59/hold-run.sh b/bench/h59/hold-run.sh new file mode 100644 index 00000000..4db2bb16 --- /dev/null +++ b/bench/h59/hold-run.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# hold-run.sh R ARM...: inside hold.sh's lock and timeout: create the hold's cluster with the +# host profile's vendors, node image and kubelet thresholds, start the P-core load sampler on a +# hybrid host (H57d's, which the desktop and wildct load gate reads), run each arm (arm.sh) until +# the profile's arms_end_s after the hold started. CLUSTER, KUBECONFIG, REPO, G, MODE, +# H59_PROFILE, H59_IMAGE (and SERVING on crescent) come from hold.sh; hold.sh deletes the cluster. +set -uo pipefail +R=$1 +shift +HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +t0=$(date +%s) +pfield() { python3 "${HERE}/run.py" profile "${H59_PROFILE:?}" | sed -n "s/^$1=//p"; } +# The k3d shim in ${G}/bin adds the campaign's model directory at /var/apb-models in the node. +K3D_GPU_VENDORS=$(pfield vendors) K3D_GPU_IMAGE=${H59_IMAGE:?} K3D_GPU_K3S_ARGS=$(pfield k3s_args) \ + K3D_GPU_CLUSTER=${CLUSTER:?} timeout 300 "${REPO:?}/scripts/k3d-gpu" up > "${R}/up.log" 2>&1 \ + || { echo "up failed"; tail -20 "${R}/up.log"; exit 1; } +kubectl get node -o json > "${R}/node.json" +docker exec "k3d-${CLUSTER}-server-0" ls /var/apb-models > /dev/null || { echo "models volume missing in the node"; exit 1; } +if [ "$(pfield sampler)" = pcore ]; then + python3 "${REPO}/bench/h57d/h57d.py" sample "${R}/load.jsonl" "k3d-${CLUSTER}-server-0" & + sp=$! + trap 'kill "${sp}" 2>/dev/null' EXIT +fi +export DEADLINE=$(( t0 + $(pfield arms_end_s) )) +for arm in "$@"; do + bash "${HERE}/arm.sh" "${arm}" "${R}/${arm}" +done diff --git a/bench/h59/hold.sh b/bench/h59/hold.sh new file mode 100644 index 00000000..af373cff --- /dev/null +++ b/bench/h59/hold.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# hold.sh HOST HOLD [REPEATS]: one H59-family hold (EXPERIMENTS.md "H59 pre-registration" and "H59 +# amendment R1") on HOST = crescent (H59, Xeon 6741P), desktop (H59d, i9-13900T) or wildct (H59w, +# i7-13700, run as bench/wildct/hold.sh's command: bench/wildct/row.sh h59w). HOLD: 1-3 perf in +# the orders (O, L), (L, O), (O, L); 4 quality in (L, O); a spare 5 or 6 repeats hold REPEATS +# in its order, when the record standing for it (the hold, or spare 5's repeat of it) left an +# arm not measured; spare 6 runs only after spare 5. Smoke runs hold 1's arms with no scored +# request (discarded, disclosed). Each hold runs once: a hold that started (its `started` file, +# written once the floors pass) counts, failed or interrupted. +# +# Before the lock it refuses (exit 2) a host whose CPU class is not the profile's (the model +# number the optimizer matches, exactly: run.py cpu), a node image not pinned by digest or not +# the one the row's first hold ran, a ${G}/bin/k3d that is not bench/h59/k3d-shim.sh, and models +# prep.sh has not left in ${G}/models. Then the host's own rules +# (run.py profile): its lock (flock -w 21600; crescent ~/k3dgpu-bench.lock, else LOCK); the +# hold-start rule (load1 <= load1_max, and on crescent no aiperf process; 2-min polls, at most 60 +# min, then the hold runs flagged); the floors (crescent: / at most 97% used; desktop and wildct: +# 15 GB free on / and MemAvailable >= 20 GiB), or no hold (exit 5). It creates cluster +# , runs the arms (hold-run.sh) under `timeout -k 30 ` with a disk watchdog +# (and crescent's serving sampler), and deletes this campaign's clusters at the end, also on +# failure. Records: ${G}/results//hold/ (smoke: ${G}/smoke/-/). +# G campaign dir (default from the profile); H59_IMAGE the k3d-gpu node image, name:tag@sha256 +# LOCK the host lock (desktop; wildct's private inner lock); K3D_REAL the shim's real k3d +# SUDO_ASKPASS or K3D_GPU_RAPL_EXEC for energy.sh's RAPL reader +# H59_CPUINFO the cpuinfo file the host check reads (default /proc/cpuinfo) +set -uo pipefail +HOST=${1:?usage: hold.sh HOST HOLD [REPEATS]} HOLD=${2:?usage: hold.sh HOST HOLD [REPEATS]} REPEATS=${3:-} +HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +REPO=$(cd "${HERE}/../.." && pwd) +RUN=${HERE}/run.py +python3 "${RUN}" profile "${HOST}" > /dev/null || exit 2 +pfield() { python3 "${RUN}" profile "${HOST}" | sed -n "s/^$1=//p"; } +ROW=$(pfield row) PREFIX=$(pfield prefix) CPU=$(pfield cpu) L1MAX=$(pfield load1_max) AIPERF=$(pfield aiperf) +DISK=$(pfield disk) MEMMIN=$(pfield mem_gib) CAP=$(pfield cap_s) SAMPLER=$(pfield sampler) +G=${G:-$(pfield g)} +G=${G/#\~/${HOME}} +order=$(python3 "${RUN}" hold "${HOLD}" ${REPEATS:+"${REPEATS}"}) || exit 2 +read -r MODE ARMS_RUN <<< "${order}" +read -r -a ARMS_RUN <<< "${ARMS_RUN}" +got=$(python3 "${RUN}" cpu "${HOST}" "${H59_CPUINFO:-/proc/cpuinfo}") \ + || { echo "hold.sh: this host's CPU class is ${got:-unknown}, not the ${CPU} that ${ROW} registers" >&2; exit 2; } +[[ "${H59_IMAGE:-}" =~ ^[^@]+:[^@]+@sha256:[0-9a-f]{64}$ ]] \ + || { echo "hold.sh: set H59_IMAGE to the k3d-gpu node image as name:tag@sha256:" >&2; exit 2; } +cmp -s "${HERE}/k3d-shim.sh" "${G}/bin/k3d" || { echo "hold.sh: ${G}/bin/k3d is not bench/h59/k3d-shim.sh (install it first)" >&2; exit 2; } +python3 "${RUN}" models "${G}" >&2 || { echo "hold.sh: run bench/h59/prep.sh ${HOST} first" >&2; exit 2; } +RES=${G}/results/${ROW} +case ${HOLD} in smoke) R=${G}/smoke/${ROW}-$(date -u +%Y%m%dT%H%M%SZ) ;; *) R=${RES}/hold${HOLD} ;; esac +[ ! -e "${R}/rc" ] && [ ! -e "${R}/started" ] \ + || { echo "hold.sh: hold ${HOLD} has run (${R}); a failed hold counts, interrupted or not, and a spare repeats it" >&2; exit 2; } +if [ -n "${REPEATS}" ]; then + why=$(python3 "${RUN}" spare-ok "${RES}" "${REPEATS}" "${HOLD}") || { echo "hold.sh: spare ${HOLD} cannot repeat hold ${REPEATS}: ${why}" >&2; exit 2; } +fi +if [ "${HOLD}" != smoke ] && [ -f "${RES}/node-image" ] && [ "$(cat "${RES}/node-image")" != "${H59_IMAGE}" ]; then + echo "hold.sh: ${ROW}'s holds run node image $(cat "${RES}/node-image"), not ${H59_IMAGE}" >&2 + exit 2 +fi +case ${HOST} in crescent) LOCKF=${HOME}/k3dgpu-bench.lock ;; *) LOCKF=${LOCK:?set LOCK to this host\'s lock file} ;; esac +mkdir -p "${R}" "${G}/results" +export REPO G MODE H59_IMAGE H59_PROFILE=${HOST} H59_G=${G} H59_PREFIX=${PREFIX} HOLDNUM=${HOLD//[^0-9]/} +export PATH=${G}/bin:${PATH} CLUSTER=${PREFIX}${HOLD} KUBECONFIG=${R}/kubeconfig +[ "${HOLD}" != smoke ] || export SMOKE=1 +log() { echo "== ${ROW}-hold${HOLD} $* $(date -u +%FT%TZ)" | tee -a "${G}/results/campaign.log"; } +free_gb() { df -P -B1G / | awk 'NR==2 {print $4}'; } +used_pct() { df -P / | awk 'NR==2 {gsub("%", "", $5); print $5}'; } +memavail_gib() { awk '/MemAvailable/{print int($2/1048576)}' /proc/meminfo; } +aiperf_n() { docker top aiperf010 -eo pid,args 2>/dev/null | awk 'NR>1 && $0 !~ /sleep infinity/' | wc -l; } +low_disk() { if [ "${DISK}" = pct97 ]; then [ "$(used_pct)" -gt 97 ]; else [ "$(free_gb)" -lt 15 ]; fi; } +snapshot() { date -u +%FT%TZ; uptime; free -m; ps -eo pcpu,pid,user,comm --sort=-pcpu | head -11; df -P / "$(readlink -f "${G}")" | tail -2; } +# This campaign's clusters only (${PREFIX}*). +mine() { k3d cluster list -o json 2>/dev/null | python3 -c 'import json,sys; print(" ".join(c["name"] for c in json.load(sys.stdin) if c["name"].startswith(sys.argv[1])))' "${PREFIX}"; } + +log "waiting for the lock (mode ${MODE}, arms: ${ARMS_RUN[*]}${REPEATS:+, repeats hold ${REPEATS}})" +exec 9> "${LOCKF}" +flock -w 21600 9 || { log "lock not taken in 21600 s"; exit 4; } +log "lock taken" +waited=0 +while :; do + l1=$(cut -d' ' -f1 /proc/loadavg) a=0 + [ "${AIPERF}" = 0 ] || a=$(aiperf_n) + if awk -v l="${l1}" -v m="${L1MAX}" 'BEGIN {exit !(l > m)}' || [ "${a}" -gt 0 ]; then + if [ "${waited}" -ge 3600 ]; then log "FLAGGED: load1 ${l1}, aiperf ${a} after 60 min"; echo "load1 ${l1} aiperf ${a}" > "${R}/flagged"; break; fi + log "busy (load1 ${l1}, aiperf ${a}): waiting 120 s" + sleep 120 + waited=$(( waited + 120 )) + continue + fi + break +done +echo "${waited}" > "${R}/wait_s" +snapshot > "${R}/pre.txt" +if low_disk; then log "/ below the disk floor: no hold"; echo "disk: $(used_pct)% used, $(free_gb) GB free" > "${R}/no-hold"; exit 5; fi +if [ "${MEMMIN}" -gt 0 ] && [ "$(memavail_gib)" -lt "${MEMMIN}" ]; then + log "MemAvailable $(memavail_gib) GiB (< ${MEMMIN}): no hold"; echo "memavail $(memavail_gib) GiB" > "${R}/no-hold"; exit 5 +fi +[ "${HOLD}" = smoke ] || [ -f "${RES}/node-image" ] || echo "${H59_IMAGE}" > "${RES}/node-image" +# From here the hold counts, also if it is interrupted before it records its rc. +date -u +%FT%TZ > "${R}/started" +# A spare names the hold it stands in for only once it runs (h59.py reads `repeats`). +[ -z "${REPEATS}" ] || echo "${REPEATS}" > "${R}/repeats" +left=$(mine) +[ -z "${left}" ] || { log "leftover clusters ${left}: deleting first"; k3d cluster delete ${left} > /dev/null 2>&1; } +bash "${HERE}/reproduce.sh" > "${R}/reproduce.txt" 2>&1 +sp="" +if [ "${SAMPLER}" = serving ]; then + python3 "${REPO}/bench/eatflow/crescent/serving.py" "${R}/serving.jsonl" & + sp=$! + export SERVING=${R}/serving.jsonl +fi +log "start: ${ARMS_RUN[*]}" +timeout -k 30 "${CAP}" bash "${HERE}/hold-run.sh" "${R}" "${ARMS_RUN[@]}" > "${R}/hold.log" 2>&1 & +cp=$! +# timeout(1) signals its whole process group; the watchdog stops that group below the disk floor. +( while kill -0 "${cp}" 2>/dev/null; do + sleep 30 + if low_disk; then echo "disk $(used_pct)% used, $(free_gb) GB free at $(date -u +%FT%TZ)" > "${R}/disk-abort"; kill -TERM "${cp}"; break; fi + done ) & +wd=$! +wait "${cp}" +rc=$? +kill "${wd}" ${sp} 2>/dev/null +[ -z "${sp}" ] || wait "${sp}" 2>/dev/null +left=$(mine) +[ -z "${left}" ] || k3d cluster delete ${left} > /dev/null 2>&1 +snapshot > "${R}/post.txt" +echo "${rc}" > "${R}/rc" +log "rc=${rc}; clusters deleted: ${left:-none}; now: $(mine)" +exit "${rc}" diff --git a/bench/h59/k3d-shim.sh b/bench/h59/k3d-shim.sh new file mode 100644 index 00000000..cb6fd100 --- /dev/null +++ b/bench/h59/k3d-shim.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# k3d-shim.sh: installed as ${G}/bin/k3d in front of the host's k3d (K3D_REAL, default +# /usr/bin/k3d). `k3d cluster create` of one of this campaign's clusters (H59_PREFIX) gets one more +# volume, the campaign's model directory at /var/apb-models in the server node, so models are read +# in place, never copied. It is writable: OVMS's --pull init container (arm O) writes graph.pbtxt +# beside the weights; arm L's pod mounts it read-only. Every other call passes through unchanged. +set -euo pipefail +REAL=${K3D_REAL:-/usr/bin/k3d} +G=${H59_G:-${HOME}/k3dgpu-h59} +if [ "${1:-}" = cluster ] && [ "${2:-}" = create ] && [ -n "${H59_PREFIX:-}" ] && [[ "${3:-}" == "${H59_PREFIX}"* ]]; then + exec "${REAL}" "$@" --volume="${G}/models:/var/apb-models@server:*" +fi +exec "${REAL}" "$@" diff --git a/bench/h59/prep.sh b/bench/h59/prep.sh new file mode 100644 index 00000000..845797a3 --- /dev/null +++ b/bench/h59/prep.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# prep.sh HOST: the H59-family model files on this host (EXPERIMENTS.md "H59 pre-registration", +# Model; "H59 amendment R1"). Preparations are not holds. +# 1. Downloads at the pinned commits, outside the lock, into ${G}/models: Google's QAT GGUF +# (gemma-4-26B_q4_0-it.gguf only) and OpenVINO's int4 IR (OVB); each file is checked against +# the Hub's LFS sha256 (bench/wildct/verify_models.py). The GGUF's directory is also reachable +# as gguf/@, the path L's render serves it from. OVB's directory and the OVMS +# compiled-model cache directory are mode 777: OVMS's --pull init container runs as uid 5000 +# and writes graph.pbtxt there at each start (H54 amendment R2's directory mode). +# 2. The QAT drafter, inside the host lock (flock -w 21600; crescent ~/k3dgpu-bench.lock, else +# LOCK) under `timeout -k 30 3600`: arm L rendered with no cluster (run.py prep-render), and +# its conversion Job's container (PR #182's path: full-b11960, its pip pins, convert_hf_to_gguf +# --outtype bf16, llama-quantize Q4_0) run once with docker against ${G}/models; the Job's +# spec is kept beside the GGUF as prep-job.json, which every hold's render must equal. +# 3. ${G}/weights.sha256: the sha256 of every file the arms read. +# G campaign dir (default from the profile); PY_HF a python with huggingface_hub +set -euo pipefail +HOST=${1:?usage: prep.sh HOST} +HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +REPO=$(cd "${HERE}/../.." && pwd) +RUN=${HERE}/run.py +pfield() { python3 "${RUN}" profile "${HOST}" | sed -n "s/^$1=//p"; } +G=${G:-$(pfield g)} +G=${G/#\~/${HOME}} +M=${G}/models +PY_HF=${PY_HF:-python3} +export HF_HUB_DISABLE_TELEMETRY=1 REPO G +read -r GREPO GREV GFILE OVB OREV DDIR <<< "$(python3 -c 'import sys; sys.path.insert(0, sys.argv[1]); import run +print(run.GGUF_REPO, run.GGUF_REV, run.GGUF_FILE, run.OVB, run.OVB_REV, run.DRAFT_DIR)' "${HERE}")" +mkdir -p "${M}/gguf" "${G}/prep" +echo "downloads $(date -u +%FT%TZ); / free $(df -P -B1G / | awk 'NR==2{print $4}') GB" +nice -n 10 "${PY_HF}" - "${M}" "${GREPO}" "${GREV}" "${GFILE}" "${OVB}" "${OREV}" <<'EOF' +import sys +from huggingface_hub import hf_hub_download, snapshot_download +m, grepo, grev, gfile, ovb, orev = sys.argv[1:] +print(hf_hub_download(grepo, gfile, revision=grev, local_dir=f"{m}/{grepo}"), flush=True) +print(snapshot_download(ovb, revision=orev, local_dir=f"{m}/{ovb}"), flush=True) +EOF +echo "${GREV}" > "${M}/${GREPO}/.pinned-revision" +echo "${OREV}" > "${M}/${OVB}/.pinned-revision" +"${PY_HF}" "${REPO}/bench/wildct/verify_models.py" "${M}" --skip gemma-4-26B-it-mmproj.gguf "${GREPO}@${GREV}" "${OVB}@${OREV}" +ln -sfn "../${GREPO}" "${M}/gguf/${GREPO//\//__}@${GREV}" +mkdir -p "${M}/.ov_cache" +chmod 777 "${M}/${OVB}" "${M}/.ov_cache" + +case ${HOST} in crescent) LOCKF=${HOME}/k3dgpu-bench.lock ;; *) LOCKF=${LOCK:?set LOCK to this host\'s lock file} ;; esac +if [ ! -f "${M}/${DDIR}/manifest.json" ]; then + J=${G}/prep/drafter + rm -rf "${J}" + python3 "${RUN}" prep-render "${J}" + mapfile -t ARGV < <(python3 "${RUN}" docker-argv "${J}/job.json" "${M}" "${J}") + exec 9> "${LOCKF}" + echo "waiting for the lock $(date -u +%FT%TZ)" + flock -w 21600 9 + echo "lock taken $(date -u +%FT%TZ); converting the drafter ($(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["name"])' "${J}/job.json"))" + timeout -k 30 3600 "${ARGV[@]}" + cp "${J}/job.json" "${M}/${DDIR}/prep-job.json" + exec 9>&- +fi +python3 -c 'import json,sys; m=json.load(open(sys.argv[1])); print("drafter:", m["source"], m["quantize"], m["sha256"], m["bytes"])' "${M}/${DDIR}/manifest.json" +( cd "${M}" && find "${GREPO}/${GFILE}" "${OVB}" "${DDIR}/model.gguf" -type f ! -path '*/.cache/*' ! -name graph.pbtxt ! -name .pinned-revision -print0 \ + | sort -z | xargs -0 sha256sum ) > "${G}/weights.sha256" +python3 "${RUN}" models "${G}" +echo "done $(date -u +%FT%TZ); / free $(df -P -B1G / | awk 'NR==2{print $4}') GB" diff --git a/bench/h59/reproduce.sh b/bench/h59/reproduce.sh new file mode 100644 index 00000000..e7d6d838 --- /dev/null +++ b/bench/h59/reproduce.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# reproduce.sh: the Reproduce header of an H59-family hold (host, CPU, kernel, microcode, tools, +# repository commit, engine and node images, model revisions and file sha256). Each arm's +# rendered and staged manifests, server command line, CPUs and fit are in its own directory +# (agent.yaml, deploy.yaml, cpus.txt, fit.json). Read-only. +set -uo pipefail +G=${G:-${HOME}/k3dgpu-h59} +HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +REPO=$(cd "${HERE}/../.." && pwd) +echo "# H59 Reproduce header (${H59_PROFILE:-?}), $(date -u +%FT%TZ)" +echo "host: $(hostname)" +echo "cpu: $(awk -F': ' '/^model name/{print $2; exit}' /proc/cpuinfo); $(nproc) CPUs; P-cores $(cat /sys/devices/cpu_core/cpus 2>/dev/null || echo none), E-cores $(cat /sys/devices/cpu_atom/cpus 2>/dev/null || echo none)" +echo "isa: amx $(grep -c amx_tile /proc/cpuinfo) avx512f $(grep -c avx512f /proc/cpuinfo) avx_vnni $(grep -c avx_vnni /proc/cpuinfo) (CPUs listing each)" +echo "microcode: $(awk -F': ' '/^microcode/{print $2; exit}' /proc/cpuinfo)" +echo "kernel: $(uname -r)" +echo "memory: $(awk '/MemTotal|MemAvailable|SwapTotal|SwapFree/{printf "%s %s kB; ", $1, $2}' /proc/meminfo)" +echo "governor: $(cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor 2>/dev/null); epp: $(cat /sys/devices/system/cpu/cpu0/cpufreq/energy_performance_preference 2>/dev/null)" +echo "docker: $(docker version --format '{{.Server.Version}}' 2>/dev/null); k3d: $("${K3D_REAL:-/usr/bin/k3d}" version | head -1); kubectl: $(kubectl version --client 2>/dev/null | head -1)" +if [ -f "${REPO}/COMMIT" ]; then echo "repo: $(cat "${REPO}/COMMIT") (archive)"; else + echo "repo: $(git -C "${REPO}" rev-parse HEAD) $(git -C "${REPO}" status --porcelain | wc -l) uncommitted"; fi +echo "k3d-gpu: $("${REPO}/scripts/k3d-gpu" version 2>/dev/null | head -1)" +echo "images:" +python3 - "${HERE}" <<'EOF' +import sys +sys.path.insert(0, sys.argv[1]) +import run +print(f" ovms: {run.OVMS_IMAGE}\n llama.cpp: {run.LLAMA_CPU_IMAGE}\n llama.cpp (drafter conversion): {run.LLAMA_FULL_IMAGE}") +print(f"models: {run.GGUF_REPO}@{run.GGUF_REV} ({run.GGUF_FILE}), {run.OVB}@{run.OVB_REV}, " + f"{run.DRAFT_REPO}@{run.DRAFT_REV} (converted by prep.sh with the render's conversion Job)") +EOF +echo " node: ${H59_IMAGE:-unset}" +echo "drafter: $(python3 -c 'import json,sys; m=json.load(open(sys.argv[1])); print(m.get("source"), m.get("convert"), m.get("quantize"), m.get("sha256"), m.get("bytes"))' \ + "${G}/models/gguf/google__gemma-4-26B-A4B-it-qat-q4_0-unquantized-assistant@9537141506fe8875b3ed45b264af13580cb29166/manifest.json" 2>/dev/null)" +echo "file sha256:" +sed 's/^/ /' "${G}/weights.sha256" 2>/dev/null +echo "data: gsm8k-test-0-199.jsonl $(sha256sum "${REPO}/bench/h54/gsm8k-test-0-199.jsonl" | cut -c1-16); ttft-prompt.txt $(sha256sum "${REPO}/bench/h54/ttft-prompt.txt" | cut -c1-16)" diff --git a/bench/h59/run.py b/bench/h59/run.py new file mode 100644 index 00000000..9b85a9df --- /dev/null +++ b/bench/h59/run.py @@ -0,0 +1,647 @@ +#!/usr/bin/env python3 +"""run.py: the H59, H59d and H59w runner's tables and steps (EXPERIMENTS.md "H59 +pre-registration", "H59 amendment R1" and "H59 amendment R2"). The verdict is h59.py's; this file serves the shell +runner (hold.sh, hold-run.sh, arm.sh, prep.sh). + + run.py profile HOST the host profile as KEY=value lines (crescent, desktop, wildct) + run.py hold HOLD [REPEATS] "MODE ARM ARM" of a registered hold: 1-3 perf, 4 quality, + the spares 5 and 6 repeating hold REPEATS in its order, + smoke as hold 1 + run.py spare-ok RESULTS K [SPARE] exit 0 when hold K ran and the record standing for it (hold K, + or spare 5 once it repeated K) has an arm not measured; spare 6 + only after spare 5 + run.py cpu HOST CPUINFO exit 0 when the CPU class (the model number the optimizer + matches a ledger row by) is the profile's, exactly + run.py models G exit 0 when prep.sh left every file the arms read in G/models + run.py render ARM OUTDIR `k3d-gpu agent manifest --model` for the arm in the hold's + cluster (REPO, CLUSTER, KUBECONFIG, H59_PROFILE, G from the + environment), then stage and check: agent.yaml, agent.err, + render-env.txt, deploy.yaml, check.json, fit.json + run.py stage ARM IN OUT the staged render: models from the node's /var/apb-models + run.py check ARM HOST DEPLOY exit 1, naming each problem, when a staged render is not the + registered arm on that host + run.py job RENDER OUTDIR an L render's drafter conversion Job: convert.sh + job.json + (prep.sh runs it once per host; holds then skip the Job) + run.py gate RESULTS crescent's load verdict for `h59.py score --load GATE.json`: + H54's void rule over the N=4 phases of the performance holds + run.py idle-gate RESULTS the desktop's and wildct's (amendment R2), for the same flag: + H57d's load gate over each arm's 60 s idle windows; the N=4 + reading is recorded beside it and does not gate + run.py prefix URL MODEL SEED OUT the prefix-reuse phase: 6 agents in sequence on one shared + prefix (H25's shape, bench/shared-prefix/probe.py) + +Python 3 standard library, except render/stage/check/job (PyYAML).""" + +import importlib.util +import json +import os +import statistics +import subprocess +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.abspath(os.path.join(HERE, "..", "..")) +sys.path.insert(0, HERE) +import h59 # noqa: E402 (hold_dirs, status: the scorer's own reading of the records) +import h57d # noqa: E402 (on the path h59 adds: the P-core foreign-load reading and the load gate) + +MODELS = "/var/apb-models" # the node path the k3d shim binds the campaign's G/models to +NS = "k3d-gpu-agent" +SERVED = "gemma" + +# The registered model files (H59 pre-registration, "Model"). +GGUF_REPO, GGUF_REV = "google/gemma-4-26B-A4B-it-qat-q4_0-gguf", "d1c082be9cf3c8a514acf63b8761f4b41935842e" +GGUF_FILE = "gemma-4-26B_q4_0-it.gguf" +DRAFT_REPO, DRAFT_REV = "google/gemma-4-26B-A4B-it-qat-q4_0-unquantized-assistant", "9537141506fe8875b3ed45b264af13580cb29166" +OVB, OVB_REV = "OpenVINO/gemma-4-26b-a4b-it-int4-ov", "4e5885c3f03844f5887982def09708bdc9f53f7a" + +# The engine images `agent manifest` pins at the runner's commit (PR #182's llama.cpp backend: +# server-b11960 on the CPU, full-b11960 for the drafter conversion; OVMS 2026.4.1). +OVMS_IMAGE = "openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" +LLAMA_CPU_IMAGE = "ghcr.io/ggml-org/llama.cpp:server-b11960@sha256:8052c13edd987de8858f8150031e025ac3d66823f09cf3ec8220899c4abce220" +LLAMA_FULL_IMAGE = "ghcr.io/ggml-org/llama.cpp:full-b11960@sha256:7afc81bfe7a93de287324044fe86ccc70b1b7ac8832ed8e6ea658e11fb448ddf" + + +def untagged(ref): + """repo@sha256:... for a digest-pinned ref, with any :tag dropped (the digest names the image).""" + name, at, digest = (ref or "").partition("@") + if at and ":" in name.rsplit("/", 1)[-1]: + name = name.rsplit(":", 1)[0] + return name + at + digest + + +def slug(repo, rev): + """`k3d-gpu`'s model-cache directory name for repo@rev (gguf_slug).""" + return f"{repo.replace('/', '__')}@{rev}" + + +GGUF_PATH = f"/models/gguf/{slug(GGUF_REPO, GGUF_REV)}/{GGUF_FILE}" +DRAFT_DIR = f"gguf/{slug(DRAFT_REPO, DRAFT_REV)}" +DRAFT_PATH = f"/models/{DRAFT_DIR}/model.gguf" + +# Arms O and L as registered: `agent manifest --model` with K3D_GPU_AGENT_DEVICE=CPU, and for L +# K3D_GPU_AGENT_DRAFT=mtp. The rest of the render environment is fixed for both (COMMON_ENV). +ARMS = { + "O": {"model": "google/gemma-4-26B-A4B-it", "revision": OVB_REV, "env": {}, + "engine": "ovms", "deploy": "ovms", "svc": "ovms", "port": 8000, "api": "/v3"}, + "L": {"model": GGUF_REPO, "revision": GGUF_REV, "env": {"K3D_GPU_AGENT_DRAFT": "mtp"}, + "engine": "llama.cpp", "deploy": "llamacpp", "svc": "llamacpp", "port": 8000, "api": "/v1"}, +} +# K3D_GPU_AGENT_REVISION pins the fit's metadata read to the commit the weights were fetched at; +# the served name is the runner's; K3D_GPU_TUNE=off keeps a host's own tuned profile out. +COMMON_ENV = {"K3D_GPU_AGENT_DEVICE": "CPU", "K3D_GPU_AGENT_MODEL_NAME": SERVED, "K3D_GPU_TUNE": "off"} +# Variables a render never inherits from the shell that starts a hold: each one would change +# what `agent manifest` renders (backend, model, images, variant, ledger, fit mode, context). +SCRUB_PREFIXES = ("K3D_GPU_AGENT_", "K3D_GPU_LLAMA_") +SCRUB = ("K3D_GPU_MODEL_OPT", "K3D_GPU_MODEL_OPT_LEDGER", "K3D_GPU_AUTOQUANT", "K3D_GPU_FIT", "K3D_GPU_FIT_RUNG", + "K3D_GPU_FIT_FILE", "K3D_GPU_CONTEXT", "K3D_GPU_KUBECONFIG", "K3D_GPU_BALLOONS") + +# Holds: performance holds 1-3 in the rotated orders, the quality hold 4 in (L, O); the spares 5 +# and 6 repeat a failed hold's arms in its order (H59 pre-registration, "Holds and caps"). +ORDERS = {1: ["O", "L"], 2: ["L", "O"], 3: ["O", "L"], 4: ["L", "O"]} +MODES = {1: "perf", 2: "perf", 3: "perf", 4: "quality"} +SPARES = (5, 6) + +# The C1 kubelet thresholds (crescent amendment C1) and wildct's absolute form (rule W3). +C1 = "--kubelet-arg=eviction-hard=memory.available<100Mi,nodefs.available<1%,imagefs.available<1%,nodefs.inodesFree<1%" +W3 = "--kubelet-arg=eviction-hard=memory.available<100Mi,nodefs.available<2Gi,imagefs.available<2Gi,nodefs.inodesFree<1%" + +# Host profiles. Each host keeps its own shared lock, start rule, floors, hold length and load +# rule (H59 pre-registration: H54's on crescent, H57d's on the desktop, the wildct +# pre-registration's on wildct; amendment R2: on the desktop and wildct, H57d's gate reads each +# arm's idle window, `run.py idle-gate`). cpu: the model number /proc/cpuinfo must name (the row's CPU +# class); pcores: the P-core balloon's CPUs the render must request (0: no hybrid balloon). +PROFILES = { + "crescent": {"row": "H59", "cpu": "6741P", "g": "~/k3dgpu-h59", "prefix": "k3dgpu-g-h59-", + "lock": "~/k3dgpu-bench.lock", "load1_max": 12, "aiperf": 1, "disk": "pct97", "mem_gib": 0, + "cap_s": 2400, "arms_end_s": 2370, "vendors": "amx", "k3s_args": C1, + "sampler": "serving", "load_rule": "h54-void", "phases": "n1,n4,n16,ttft,prefix", + "pcores": 0}, + "desktop": {"row": "H59d", "cpu": "i9-13900T", "g": "~/k3dgpu-h59", "prefix": "k3dgpu-d-h59-", + "lock": "", "load1_max": 24, "aiperf": 0, "disk": "free15", "mem_gib": 20, + "cap_s": 4500, "arms_end_s": 4470, "vendors": "hybrid", "k3s_args": C1, + "sampler": "pcore", "load_rule": "h57d-idle", "phases": "n1,n4,ttft,prefix", "pcores": 16}, + "wildct": {"row": "H59w", "cpu": "i7-13700", "g": "~/k3dgpu-bench-wildct/h59", "prefix": "k3dgpu-w-h59-", + "lock": "", "load1_max": 12, "aiperf": 0, "disk": "free15", "mem_gib": 20, + "cap_s": 4500, "arms_end_s": 4470, "vendors": "auto,-nvidia,-intel", "k3s_args": W3, + "sampler": "pcore", "load_rule": "h57d-idle", "phases": "n1,n4,ttft,prefix", "pcores": 16}, +} +# Phases: (name, requests, concurrency, max_tokens) for bench/llm_load.py, H54's requests. +LOAD_PHASES = {"warmup": (3, 1, 256), "n1": (8, 1, 256), "n4": (16, 4, 256), "n16": (32, 16, 256), + "ttft": (4, 1, 16)} + + +def jload(path): + with open(path) as f: + return json.load(f) + + +def profile(host): + if host not in PROFILES: + raise SystemExit(f"run.py: unknown host profile {host!r} (crescent, desktop, wildct)") + return PROFILES[host] + + +def hold(n, repeats=None): + """(mode, arms) of a registered hold; SystemExit for anything else.""" + if n == "smoke" and repeats is None: + return "perf", ORDERS[1] + if n in [str(k) for k in ORDERS] and repeats is None: + return MODES[int(n)], ORDERS[int(n)] + if n in [str(k) for k in SPARES] and repeats in [str(k) for k in ORDERS]: + return MODES[int(repeats)], ORDERS[int(repeats)] + raise SystemExit(f"run.py hold: no registered hold {n!r}{' repeating ' + repr(repeats) if repeats else ''} " + "(1-3 perf, 4 quality, a spare 5 or 6 names the hold it repeats)") + + +def ran(d): + """A hold ran once it started (hold.sh writes `started` when the floors pass, before anything + runs) or recorded its rc.""" + return any(os.path.exists(os.path.join(d, f)) for f in ("started", "rc")) + + +def spare_ok(results, k, spare=None): + """A spare repeats hold k only when hold k ran and the record that stands for hold k now + (hold k, or spare 5 once it repeated hold k: h59.hold_dirs) has an arm not measured. Spare 6 + runs only after spare 5: h59.py reads spare 6 over spare 5 for the same hold, so the later + spare is the one it scores.""" + mode, arms = hold(str(k)) + d = os.path.join(results, f"hold{k}") + if not ran(d): + return False, f"hold {k} has not run" + if str(spare) == str(SPARES[1]) and not ran(os.path.join(results, f"hold{SPARES[0]}")): + return False, f"spare {SPARES[1]} runs only after spare {SPARES[0]}" + cur = h59.hold_dirs(results)[int(k)] + where = f"hold {k}" if cur == d else f"spare {os.path.basename(cur)[4:]}, which repeated hold {k}," + if all(h59.status(os.path.join(cur, a)) == "measured" for a in arms): + return False, f"every arm of {where} was measured" + return True, f"{where} has an arm not measured" + + +def _model_opt(): + spec = importlib.util.spec_from_file_location("model_opt", os.path.join(REPO, "console", "model_opt.py")) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def cpu_class(cpuinfo): + """The CPU class the optimizer matches a ledger row by: console/model_opt.py class_tokens of + the first `model name` line, as `k3d-gpu` reads it ("6741P", "i9-13900T", "i7-13700").""" + name = "" + with open(cpuinfo) as f: + for line in f: + if line.startswith("model name"): + name = line.partition(":")[2].strip() + break + return " ".join(_model_opt().class_tokens(f"cpu:{name}")) if name else "" + + +def models_ready(g): + """The files the staged arms read, under G/models (prep.sh's layout); [] when all are there.""" + m = os.path.join(g, "models") + want = [os.path.join(m, GGUF_PATH[len("/models/"):]), os.path.join(m, DRAFT_DIR, "model.gguf"), + os.path.join(m, DRAFT_DIR, "manifest.json"), os.path.join(m, DRAFT_DIR, "prep-job.json"), + os.path.join(m, OVB, "openvino_model.xml"), os.path.join(m, OVB, "openvino_model.bin")] + return [p for p in want if not os.path.exists(p)] + + +# ---- YAML render steps --------------------------------------------------------------------------- + +def load_docs(path): + import yaml + with open(path) as f: + return [d for d in yaml.safe_load_all(f) if d] + + +def dump_docs(docs, path): + import yaml + with open(path, "w") as f: + yaml.safe_dump_all(docs, f, sort_keys=False) + + +def deployment(docs): + ds = [d for d in docs if d.get("kind") == "Deployment"] + if len(ds) != 1: + raise SystemExit(f"run.py: the render holds {len(ds)} Deployments, not 1") + return ds[0] + + +def _apb_stage(): + spec = importlib.util.spec_from_file_location("apb_stage", os.path.join(REPO, "bench", "apb", "stage.py")) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def stage(arm, docs): + """O: `stage.py ovms` (the PVC becomes the node's /var/apb-models; OVMS's --pull init + container writes graph.pbtxt there from this hold's own arguments, and finds the weights + prep.sh fetched at the pinned commit, so it downloads nothing). L: the PVC becomes the same + hostPath, mounted read-only; the drafter's conversion ConfigMap and Job and the init container + that waits for them are dropped (prep.sh ran that Job once, so each is a no-op), and so is + `--model-url` with its value (llama-server's download source; the GGUF is already at --model). + Image, every other argument, requests, limits and labels are as rendered.""" + docs = json.loads(json.dumps(docs)) + if arm == "O": + return _apb_stage().stage("ovms", docs) + if arm != "L": + raise SystemExit(f"run.py stage: unknown arm {arm}") + out = [] + for d in docs: + kind, name = d.get("kind"), (d.get("metadata") or {}).get("name", "") + if kind == "PersistentVolumeClaim" or (kind == "ConfigMap" and name == "gguf-convert") \ + or (kind == "Job" and name.startswith("gguf-convert-")): + continue + if kind == "Deployment": + ps = d["spec"]["template"]["spec"] + init = [c["name"] for c in ps.get("initContainers") or []] + if init not in ([], ["wait-gguf"]): + raise SystemExit(f"run.py stage L: unexpected init containers {init}") + ps.pop("initContainers", None) + for v in ps["volumes"]: + if v["name"] == "models": + v.pop("persistentVolumeClaim", None) + v["hostPath"] = {"path": MODELS, "type": "Directory"} + c = ps["containers"][0] + for m in c["volumeMounts"]: + if m["name"] == "models": + m["readOnly"] = True + a = c["args"] + if "--model-url" in a: + i = a.index("--model-url") + del a[i:i + 2] + out.append(d) + return out + + +def args_of(c): + return [str(x) for x in c.get("args") or []] + + +def value(args, opt): + """The argument after opt (llama-server takes an option's value from the next argument).""" + return args[args.index(opt) + 1] if opt in args and args.index(opt) + 1 < len(args) else None + + +def check(arm, host, docs): + """Problems that make a staged render not the registered arm on this host; [] when none.""" + p = profile(host) + bad = [] + try: + dep = deployment(docs) + except SystemExit as e: + return [str(e)] + want = ARMS[arm] + if dep["metadata"].get("name") != want["deploy"] or dep["metadata"].get("namespace") != NS: + bad.append(f"Deployment {dep['metadata'].get('namespace')}/{dep['metadata'].get('name')}, " + f"not {NS}/{want['deploy']}") + tpl = dep["spec"]["template"] + ps = tpl["spec"] + c = ps["containers"][0] + a = args_of(c) + ann = (tpl["metadata"].get("annotations") or {}) + if ann.get("k3d-gpu.io/agent-device") != "CPU": + bad.append(f"agent device {ann.get('k3d-gpu.io/agent-device')!r}, not CPU") + if arm == "O": + if untagged(c.get("image")) != untagged(OVMS_IMAGE): + bad.append(f"image {c.get('image')}, not {OVMS_IMAGE}") + pull = [x for x in ps.get("initContainers") or [] if x["name"] == "pull"] + pa = args_of(pull[0]) if pull else [] + for need in ("--pull", f"--source_model={OVB}", "--target_device=CPU", "--task=text_generation", + f"--model_name={SERVED}"): + if need not in pa: + bad.append(f"pull container lacks {need}") + if any(x.startswith("--draft_") for x in pa + a): + bad.append("O renders a draft (registered: no draft)") + if f"--model_path=/models/{OVB}" not in a: + bad.append(f"server does not serve /models/{OVB}") + else: + if untagged(c.get("image")) != untagged(LLAMA_CPU_IMAGE): + bad.append(f"image {c.get('image')}, not {LLAMA_CPU_IMAGE}") + for opt, v in (("--model", GGUF_PATH), ("--spec-draft-model", DRAFT_PATH), ("--spec-type", "draft-mtp"), + ("--alias", SERVED), ("--reasoning", "off")): + if value(a, opt) != v: + bad.append(f"{opt} {value(a, opt)!r}, not {v!r}") + for opt in ("--jinja",): + if opt not in a: + bad.append(f"no {opt}") + for opt in ("-c", "-np", "--threads", "--threads-batch"): + if value(a, opt) is None or not str(value(a, opt)).isdigit(): + bad.append(f"no numeric {opt}") + if "--model-url" in a: + bad.append("--model-url left in (a hold never downloads)") + if any(x.get("name") == "models" and not x.get("readOnly") for x in c.get("volumeMounts") or []): + bad.append("models mounted read-write") + if ps.get("initContainers"): + bad.append("init containers left in") + if p["pcores"] and value(a, "--threads") != str(p["pcores"] // 2): + bad.append(f"--threads {value(a, '--threads')}, not the {p['pcores'] // 2} physical P-cores") + if not any(v.get("name") == "models" and (v.get("hostPath") or {}).get("path") == MODELS for v in ps.get("volumes") or []): + bad.append(f"models volume is not hostPath {MODELS}") + if any(d.get("kind") == "PersistentVolumeClaim" for d in docs): + bad.append("a PersistentVolumeClaim is left in") + req = ((c.get("resources") or {}).get("requests") or {}) + lim = ((c.get("resources") or {}).get("limits") or {}) + if any("gpu" in k or "nvidia" in k for k in list(req) + list(lim)): + bad.append("a GPU resource is requested") + label = (tpl["metadata"].get("labels") or {}).get("k3d-gpu.io/cores") + if p["pcores"]: + # The P-core balloon (H57dp's deviation: a render that misses the hold's cluster gets no + # hybrid node, so no label and 1 CPU). + if label != "performance": + bad.append(f"pod label k3d-gpu.io/cores {label!r}, not performance (the P-core balloon)") + if str(req.get("cpu")) != str(p["pcores"]): + bad.append(f"CPU request {req.get('cpu')!r}, not the {p['pcores']} P-core CPUs") + if arm == "L" and value(a, "--threads-batch") != str(p["pcores"]): + bad.append(f"--threads-batch {value(a, '--threads-batch')}, not {p['pcores']}") + elif label is not None: + bad.append(f"pod label k3d-gpu.io/cores {label!r} on a host with no hybrid balloon") + return bad + + +def fit_record(arm, docs): + """The fit the arm ran with, from the staged render.""" + dep = deployment(docs) + tpl = dep["spec"]["template"] + ps = tpl["spec"] + c = ps["containers"][0] + a = args_of(c) + rec = {"arm": arm, "fit": (tpl["metadata"].get("annotations") or {}).get("k3d-gpu.io/agent-fit"), + "requests": (c.get("resources") or {}).get("requests"), "limits": (c.get("resources") or {}).get("limits"), + "image": c.get("image"), "args": a} + if arm == "O": + pull = [x for x in ps.get("initContainers") or [] if x["name"] == "pull"] + pa = args_of(pull[0]) if pull else [] + rec["pull_args"] = pa + for k in ("max_num_seqs", "cache_size", "enable_prefix_caching"): + v = [x.split("=", 1)[1] for x in pa if x.startswith(f"--{k}=")] + rec[k] = v[0] if v else None + else: + for k in ("-c", "-np", "-ngl", "--threads", "--threads-batch", "--load-mode"): + rec[k.lstrip("-")] = value(a, k) + return rec + + +def job_spec(docs): + """The drafter conversion Job of an L render (image, command, args, env) and its script.""" + jobs = [d for d in docs if d.get("kind") == "Job" and d["metadata"]["name"].startswith("gguf-convert-")] + cms = [d for d in docs if d.get("kind") == "ConfigMap" and d["metadata"]["name"] == "gguf-convert"] + if len(jobs) != 1 or len(cms) != 1: + raise SystemExit(f"run.py job: the render holds {len(jobs)} conversion Jobs and {len(cms)} ConfigMaps, not 1 and 1") + c = jobs[0]["spec"]["template"]["spec"]["containers"][0] + spec = {"name": jobs[0]["metadata"]["name"], "image": c["image"], "command": c["command"], + "args": [str(x) for x in c["args"]], "env": {e["name"]: str(e["value"]) for e in c.get("env") or []}, + "resources": c.get("resources")} + return spec, cms[0]["data"]["convert.sh"] + + +def write_job(render_yaml, outdir): + spec, script = job_spec(load_docs(render_yaml)) + os.makedirs(outdir, exist_ok=True) + with open(os.path.join(outdir, "job.json"), "w") as f: + json.dump(spec, f, indent=1) + with open(os.path.join(outdir, "convert.sh"), "w") as f: + f.write(script) + return spec + + +def prep_render(outdir): + """prep.sh's L render, with no cluster (the conversion Job does not depend on one): a scratch + kubeconfig keeps every kubectl call off the host's clusters. Writes agent.yaml, agent.err, + job.json and convert.sh.""" + repo = os.environ.get("REPO", REPO) + os.makedirs(outdir, exist_ok=True) + env = render_env("L", os.environ, "h59-prep") + env["KUBECONFIG"] = os.path.join(outdir, "kubeconfig") + open(env["KUBECONFIG"], "a").close() + cmd = [os.path.join(repo, "scripts", "k3d-gpu"), "agent", "manifest", "--model", ARMS["L"]["model"]] + with open(os.path.join(outdir, "agent.yaml"), "w") as fo, open(os.path.join(outdir, "agent.err"), "w") as fe: + rc = subprocess.run(cmd, env=env, stdout=fo, stderr=fe).returncode + if rc != 0: + raise SystemExit(f"prep render failed: agent manifest exited {rc}") + return write_job(os.path.join(outdir, "agent.yaml"), outdir) + + +def docker_argv(spec, models, scripts): + """`docker run` of the conversion Job's container on this host: its image, command, arguments + and environment, the campaign's models at /models and the Job's script at /scripts, run as + the campaign user, under the Job's memory limit.""" + if spec["image"] != LLAMA_FULL_IMAGE: + raise SystemExit(f"run.py: the conversion Job runs {spec['image']}, not {LLAMA_FULL_IMAGE}") + if spec["command"] != ["bash", "/scripts/convert.sh"]: + raise SystemExit(f"run.py: unexpected conversion command {spec['command']}") + argv = ["docker", "run", "--rm", "-u", f"{os.getuid()}:{os.getgid()}"] + lim = ((spec.get("resources") or {}).get("limits") or {}).get("memory") + if lim: + argv += ["--memory", lim.replace("Gi", "g")] + for k, v in spec["env"].items(): + argv += ["-e", f"{k}={v}"] + argv += ["-v", f"{models}:/models", "-v", f"{scripts}:/scripts:ro", "--entrypoint", "bash", spec["image"], + "/scripts/convert.sh", *spec["args"]] + return argv + + +def render_env(arm, environ, cluster): + env = {k: v for k, v in environ.items() + if not k.startswith(SCRUB_PREFIXES) and k not in SCRUB} + env.update(COMMON_ENV) + env.update(ARMS[arm]["env"]) + env["K3D_GPU_AGENT_REVISION"] = ARMS[arm]["revision"] + # The hold's cluster: `agent manifest` reads the node's hybrid label and allocatable CPU + # through context k3d-${K3D_GPU_CLUSTER}; without it that is k3d-gpu, which a hold's own + # kubeconfig does not hold (H57dp's Od deviation). + env["K3D_GPU_CLUSTER"] = cluster + return env + + +def render(arm, out): + """Render, stage, check and record one arm in the hold's cluster; exit 1 with the reason + when the render fails or is not the registered arm.""" + host, cluster = os.environ.get("H59_PROFILE", ""), os.environ.get("CLUSTER", "") + repo = os.environ.get("REPO", REPO) + if not cluster: + raise SystemExit("run.py render: CLUSTER (the hold's cluster) is not set") + profile(host) + os.makedirs(out, exist_ok=True) + env = render_env(arm, os.environ, cluster) + with open(os.path.join(out, "render-env.txt"), "w") as f: + f.writelines(f"{k}={env[k]}\n" for k in sorted(env) if k.startswith("K3D_GPU_") or k == "KUBECONFIG") + cmd = [os.path.join(repo, "scripts", "k3d-gpu"), "agent", "manifest", "--model", ARMS[arm]["model"]] + with open(os.path.join(out, "agent.yaml"), "w") as fo, open(os.path.join(out, "agent.err"), "w") as fe: + rc = subprocess.run(cmd, env=env, stdout=fo, stderr=fe).returncode + if rc != 0: + raise SystemExit(f"render failed: agent manifest exited {rc}") + docs = load_docs(os.path.join(out, "agent.yaml")) + rec = {"arm": arm, "host": host, "problems": []} + if arm == "L": + spec, _ = job_spec(docs) + rec["job"] = spec + g = os.path.expanduser(os.environ.get("G", "")) + pj = os.path.join(g, "models", DRAFT_DIR, "prep-job.json") + if not g or not os.path.exists(pj): + rec["problems"].append(f"no prep-job.json for the drafter at {pj}") + elif jload(pj) != spec: + rec["problems"].append("the render's drafter conversion Job differs from the one prep.sh ran") + staged = stage(arm, docs) + dump_docs(staged, os.path.join(out, "deploy.yaml")) + rec["problems"] += check(arm, host, staged) + fit = fit_record(arm, staged) + seqs = fit.get("max_num_seqs") if arm == "O" else fit.get("np") + # Reported, not gated: the arm runs at the fit's sequences, as registered. + rec["notes"] = [f"the fit gives {seqs} sequences (< 4): the N=4 phase queues"] \ + if seqs is not None and str(seqs).isdigit() and int(seqs) < 4 else [] + with open(os.path.join(out, "fit.json"), "w") as f: + json.dump(fit, f, indent=1) + with open(os.path.join(out, "check.json"), "w") as f: + json.dump(rec, f, indent=1) + if rec["problems"]: + raise SystemExit("render not the registered arm: " + "; ".join(rec["problems"])) + + +# ---- crescent's load rule ------------------------------------------------------------------------ + +def gate(results): + """H54's rule on crescent (amendment R4): a phase whose idle window read non-campaign CPU + above 4 CPU-s/s, or that saw an aiperf process, is void and re-run once. The row's load is + unmatched when an N=4 phase of a performance hold (a spare standing in for the hold it + repeats) was void on both tries, or carries no void check.""" + hd = h59.hold_dirs(results) + void, checked = [], 0 + for k in h59.PERF_HOLDS: + for arm in ("O", "L"): + ad = os.path.join(hd[k], arm) + if h59.status(ad) != "measured": + continue + p = os.path.join(ad, "phases.jsonl") + tries = [] + if os.path.exists(p): + with open(p) as f: + tries = [r for r in (json.loads(x) for x in f if x.strip()) if r.get("phase") == "n4"] + last = tries[-1] if tries else None + if not last or "check" not in last: + void.append(f"hold {k} {arm}: no void check for N=4") + elif last["check"].get("void"): + void.append(f"hold {k} {arm}: N=4 void on {len(tries)} tries ({last['check'].get('reason')})") + checked += 1 + if void: + return {"matched": False, "reason": "; ".join(void), "rule": "H54's void rule over the N=4 phases", + "checked": checked} + return {"matched": True, "reason": f"no N=4 phase void after its re-run ({checked} checked)", + "rule": "H54's void rule over the N=4 phases", "checked": checked} + + +# ---- the desktop's and wildct's load rule ---------------------------------------------------------- + +def idle_gate(results, arms=None): + """H59d's and H59w's load gate (amendment R2): H57d's load gate over each arm's mean foreign + P-core load in its 60 s idle windows (server up, no request) of the performance holds, a spare + standing in for the hold it repeats. arms: {role: record directory name}, default L and O. + Amendment S1's reading over the N=4 phases is recorded beside it (n4) and does not gate.""" + names = list((arms or {"L": "L", "O": "O"}).values()) + hd = h59.hold_dirs(results) + means = {} + for a in names: + f = [] + for k in h59.PERF_HOLDS: + ph, sp = os.path.join(hd[k], a, "phases.jsonl"), os.path.join(hd[k], "load.jsonl") + if h59.status(os.path.join(hd[k], a)) != "measured" or not (os.path.exists(ph) and os.path.exists(sp)): + continue + with open(sp) as fh: + samples = [json.loads(x) for x in fh if x.strip()] + with open(ph) as fh: + for r in (json.loads(x) for x in fh if x.strip()): + if r.get("phase") == "idle": + v = h57d.foreign(samples, r["t0"], r["t1"]) + if v is not None: + f.append(v) + means[a] = statistics.mean(f) if f else None + return dict(h57d.load_gate(means), means=means, + rule="H57d's load gate over each arm's 60 s idle window (H59 amendment R2)", + n4=dict(h59.h57d_load(results, names), gates=False)) + + +# ---- the prefix-reuse phase ---------------------------------------------------------------------- + +def _probe(): + spec = importlib.util.spec_from_file_location("h25_probe", os.path.join(REPO, "bench", "shared-prefix", "probe.py")) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def prefix(url, model, seed, out): + """H25's phase S without its control: agents a1-a6 in sequence, each the byte-identical + system text and 12 tool schemas plus its own 96-token message, max_tokens 8, thinking off, + streamed. The score is the median first-chunk TTFT of agents 2-6.""" + probe = _probe() + probe.MODEL = model + p = probe.gen(seed) + chat = url.rstrip("/") + "/chat/completions" + agents = [dict(probe.request(chat, p["system"], u, p["tools"], 8), agent=f"a{n + 1}") for n, u in enumerate(p["a"])] + added = [a["ttft"] for a in agents[1:] if a["ttft"] is not None] + rec = {"seed": seed, "model": model, "agents": agents, "first_ttft_s": agents[0]["ttft"], + "added_ttft_median_s": statistics.median(added) if len(added) == 5 else None, + "prompt_tokens": sorted({str(a["prompt_tokens"]) for a in agents})} + with open(out, "w") as f: + json.dump(rec, f, indent=1) + return rec + + +def main(argv): + if not argv: + raise SystemExit(__doc__) + cmd, a = argv[0], argv[1:] + if cmd == "profile": + for k, v in profile(a[0]).items(): + print(f"{k}={v}") + elif cmd == "arms": + print(json.dumps({"arms": ARMS, "orders": ORDERS, "modes": MODES, "common_env": COMMON_ENV, + "phases": LOAD_PHASES}, indent=1)) + elif cmd == "field": + print(ARMS[a[0]][a[1]]) + elif cmd == "hold": + mode, arms = hold(a[0], a[1] if len(a) > 1 else None) + print(mode, *arms) + elif cmd == "spare-ok": + ok, why = spare_ok(a[0], a[1], a[2] if len(a) > 2 else None) + print(why) + return 0 if ok else 1 + elif cmd == "cpu": + got = cpu_class(a[1]) + print(got) + return 0 if got.lower() == profile(a[0])["cpu"].lower() else 1 + elif cmd == "models": + missing = models_ready(os.path.expanduser(a[0])) + for m in missing: + print(f"missing {m}") + return 1 if missing else 0 + elif cmd == "render": + render(a[0], a[1]) + elif cmd == "stage": + dump_docs(stage(a[0], load_docs(a[1])), a[2]) + elif cmd == "check": + bad = check(a[0], a[1], load_docs(a[2])) + for b in bad: + print(b) + return 1 if bad else 0 + elif cmd == "job": + write_job(a[0], a[1]) + elif cmd == "prep-render": + prep_render(a[0]) + elif cmd == "docker-argv": + print("\n".join(docker_argv(jload(a[0]), a[1], a[2]))) + elif cmd == "gate": + print(json.dumps(gate(a[0]), indent=1)) + elif cmd == "idle-gate": + print(json.dumps(idle_gate(a[0]), indent=1)) + elif cmd == "prefix": + rec = prefix(a[0], a[1], int(a[2]), a[3]) + print(json.dumps({"added_ttft_median_s": rec["added_ttft_median_s"], "first_ttft_s": rec["first_ttft_s"]})) + else: + raise SystemExit(f"run.py: unknown command {cmd}") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/bench/h59/test_run.py b/bench/h59/test_run.py new file mode 100644 index 00000000..a4013677 --- /dev/null +++ b/bench/h59/test_run.py @@ -0,0 +1,896 @@ +#!/usr/bin/env python3 +"""Tests for the H59-family runner (bench/h59: run.py, arm.sh, hold.sh, hold-run.sh, prep.sh; +EXPERIMENTS.md "H59 amendment R1"). Offline: `k3d-gpu agent manifest` renders against the fit +fixtures (tests/fixtures/fit, tests/fixtures/hf) with a kubectl stub that answers only for the +hold's own context, so a render that misses the hold's cluster is caught; arm.sh runs against a +local OpenAI-compatible server with kubectl and docker stubbed, and the records it writes are +scored by h59.py, so the runner's N=4 holds feed the registered bootstrap-ratio scorer end to +end. No cluster, no network.""" +import http.server +import json +import os +import shutil +import subprocess +import sys +import tempfile +import threading +import time +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) +sys.path.insert(0, HERE) +import h59 # noqa: E402 +import run # noqa: E402 + +FIX = os.path.join(ROOT, "tests", "fixtures", "fit") +HOSTS = {"desktop": "desktop-i9-nogpu.json", "wildct": "desktop-i9-nogpu.json", "crescent": "xeon6741p-4xb70.json"} +NODE_IMAGE = "cryptoandcoffee/k3d-gpu:0.1.80@sha256:" + "a" * 64 + +KUBECTL = r'''#!/usr/bin/env python3 +"""kubectl stand-in: the launcher's calls name --context and are answered only for +k3d-${STUB_CLUSTER} ("context was not found" otherwise, as a hold's own kubeconfig answers); +the runner's own calls name no context.""" +import os, shutil, sys +a = sys.argv[1:] +ctx = None +while a and a[0] == "--context": + ctx, a = a[1], a[2:] +d = os.environ["STUB_DIR"] +with open(os.path.join(d, "kubectl.log"), "a") as f: + f.write(f"ctx={ctx} {' '.join(a)}\n") +if ctx is not None and ctx != "k3d-" + os.environ.get("STUB_CLUSTER", ""): + sys.exit(f"error: context was not found for specified context: {ctx}") +s = " ".join(a) +if s == "get nodes -l k3d-gpu.io/hybrid-cpu=true -o name": + if os.environ.get("STUB_HYBRID"): + print("node/k3d-x-server-0") +elif "allocatable" in s: + print(os.environ.get("STUB_ALLOC", "cpu 32")) +elif a[:2] == ["apply", "-f"]: + shutil.copy(a[2], os.path.join(d, "applied.yaml")) + sys.exit(int(os.environ.get("STUB_APPLY_RC", "0"))) +elif "port-forward" in a: + os.execvp("sleep", ["sleep", "600"]) +elif "{.items[*].status.phase}" in s: + print("Running") +elif "{.items[0].metadata.name}" in s: + print("pod-0", end="") +elif "{.spec.containers[0].name}" in s: + print("server", end="") +elif "{.spec.containers[0].image}" in s: + print("image@sha256:0 imageid", end="") +elif "-o json" in s: + print("{}") +elif "exec" in a: + print("cpuset 0-15\nCpus_allowed_list:\t0-15") +''' + +DOCKER = r'''#!/bin/sh +case "$1" in + inspect) echo deadbeef ;; + top) exit 1 ;; +esac +exit 0 +''' + + +def jl(path): + with open(path) as f: + return json.load(f) + + +def rd(path): + with open(path) as f: + return f.read() + + +def put(path, text, mode=None): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as f: + f.write(text) + if mode: + os.chmod(path, mode) + + +class Fake(http.server.ThreadingHTTPServer): + """OpenAI-compatible stand-in: /v1 (L) and /v3 (O) stream at their own inter-token gap; a + non-streamed chat answers one fixed sentence. Every request body is kept.""" + daemon_threads = True + + def __init__(self, itl): + self.itl, self.bodies = itl, [] + super().__init__(("127.0.0.1", 0), Handler) + + +class Handler(http.server.BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, *a): + pass + + def reply(self, obj): + raw = json.dumps(obj).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + def do_GET(self): + if self.path.endswith("/models"): + return self.reply({"object": "list", "data": [{"id": "gemma"}]}) + self.send_error(404) + + def do_POST(self): + body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0)))) + self.server.bodies.append((self.path, body)) + itl = self.server.itl[self.path.split("/")[1]] + n = body.get("max_tokens") or 16 + if not body.get("stream"): + return self.reply({"choices": [{"index": 0, "message": {"role": "assistant", "content": "The answer is 0."}, + "finish_reason": "stop"}], "usage": {"prompt_tokens": 9, "completion_tokens": 5}}) + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Transfer-Encoding", "chunked") + self.end_headers() + + def chunk(obj): + raw = f"data: {obj if isinstance(obj, str) else json.dumps(obj)}\n\n".encode() + self.wfile.write(f"{len(raw):x}\r\n".encode() + raw + b"\r\n") + self.wfile.flush() + chunk({"choices": [{"index": 0, "delta": {"role": "assistant", "content": None}, "finish_reason": None}]}) + for i in range(n): + time.sleep(itl) + chunk({"choices": [{"index": 0, "delta": {"content": f"t{i} "}, "finish_reason": "length" if i == n - 1 else None}]}) + if (body.get("stream_options") or {}).get("include_usage"): + chunk({"choices": [], "usage": {"prompt_tokens": 1600, "completion_tokens": n}}) + chunk("[DONE]") + self.wfile.write(b"0\r\n\r\n") + + +def serve(itl): + srv = Fake(itl) + threading.Thread(target=srv.serve_forever, daemon=True).start() + return srv + + +class Box: + """A scratch host: fit fixtures, a sysroot (hybrid i9 or the Xeon), kubectl and docker stubs, + a fake RAPL zone, the campaign dir G with prep's drafter Job record.""" + + def __init__(self, host, cluster="k3dgpu-d-h59-1", stub_cluster=None): + self.d = tempfile.mkdtemp(prefix="h59-") + d = self.d + self.host, self.cluster = host, cluster + put(os.path.join(d, "bin", "kubectl"), KUBECTL, 0o755) + put(os.path.join(d, "bin", "docker"), DOCKER, 0o755) + os.makedirs(os.path.join(d, "cache")) + for n in os.listdir(os.path.join(FIX, "models")): + src = os.path.join(FIX, "models", n) + os.symlink(src, os.path.join(d, "cache", f"{n}@main")) + rev = jl(os.path.join(src, "weights.json")).get("source", "").partition("@")[2] + if rev: + os.symlink(src, os.path.join(d, "cache", f"{n}@{rev}")) + # O's render pins K3D_GPU_AGENT_REVISION to OVB's commit, and the launcher also reads the + # requested base repo's format at that revision (agent_model_format). On a host that read + # finds no such commit and the format is unknown; here it reads the base's own metadata, + # which is not GGUF either, so the render takes the same path without the network. + os.symlink(os.path.join(FIX, "models", "google__gemma-4-26B-A4B-it"), + os.path.join(d, "cache", f"google__gemma-4-26B-A4B-it@{run.OVB_REV}")) + sysd = os.path.join(d, "root", "sys", "devices") + hybrid = run.PROFILES[host]["pcores"] > 0 + for c in range(32 if hybrid else 96): + if hybrid: + sib = f"{c // 2 * 2}-{c // 2 * 2 + 1}" if c < 16 else str(c) + else: + sib = f"{c % 48},{c % 48 + 48}" + put(os.path.join(sysd, "system", "cpu", f"cpu{c}", "topology", "core_cpus_list"), sib + "\n") + if hybrid: + put(os.path.join(sysd, "cpu_core", "cpus"), "0-15\n") + put(os.path.join(sysd, "cpu_atom", "cpus"), "16-31\n") + rapl = os.path.join(d, "powercap", "intel-rapl:0") + put(os.path.join(rapl, "name"), "package-0\n") + put(os.path.join(rapl, "energy_uj"), "123456789\n") + put(os.path.join(rapl, "max_energy_range_uj"), "262143328850\n") + os.makedirs(os.path.join(d, "home")) + os.makedirs(os.path.join(d, "stub")) + put(os.path.join(d, "kubeconfig"), "") + self.g = os.path.join(d, "g") + self.env = { + "PATH": os.path.join(d, "bin") + ":/usr/local/bin:/usr/bin:/bin", "HOME": os.path.join(d, "home"), + "LANG": "C.UTF-8", "K3D_GPU_SYSROOT": os.path.join(d, "root"), "K3D_GPU_FIT_CACHE": os.path.join(d, "cache"), + "XDG_STATE_HOME": os.path.join(d, "state"), "XDG_CACHE_HOME": os.path.join(d, "xdg"), + "K3D_GPU_HF_FIXTURES": os.path.join(ROOT, "tests", "fixtures", "hf"), + "K3D_GPU_AUTOQUANT_CACHE": os.path.join(d, "no-autoquant.json"), + "K3D_GPU_FIT_HOST": os.path.join(FIX, "hosts", HOSTS[host]), + "KUBECONFIG": os.path.join(d, "kubeconfig"), "STUB_DIR": os.path.join(d, "stub"), + "STUB_CLUSTER": stub_cluster or cluster, "STUB_ALLOC": "cpu 32" if hybrid else "cpu 64", + "REPO": ROOT, "G": self.g, "CLUSTER": cluster, "H59_PROFILE": host, + "ENERGY_POWERCAP": os.path.join(d, "powercap"), "H59_IDLE_S": "1", + } + if hybrid: + self.env["STUB_HYBRID"] = "1" + + def prep_job(self): + """prep.sh's drafter record: the conversion Job of an L render with no cluster.""" + out = os.path.join(self.d, "prep") + r = subprocess.run([sys.executable, os.path.join(HERE, "run.py"), "prep-render", out], env=self.env, + capture_output=True, text=True) + assert r.returncode == 0, r.stderr + rd(os.path.join(out, "agent.err")) + dst = os.path.join(self.g, "models", run.DRAFT_DIR) + os.makedirs(dst, exist_ok=True) + shutil.copy(os.path.join(out, "job.json"), os.path.join(dst, "prep-job.json")) + return jl(os.path.join(out, "job.json")) + + def render(self, arm, out, **extra): + env = dict(self.env, **extra) + return subprocess.run([sys.executable, os.path.join(HERE, "run.py"), "render", arm, out], env=env, + capture_output=True, text=True) + + def close(self): + shutil.rmtree(self.d, ignore_errors=True) + + +class Tables(unittest.TestCase): + def test_orders_as_registered(self): + self.assertEqual(run.ORDERS, {1: ["O", "L"], 2: ["L", "O"], 3: ["O", "L"], 4: ["L", "O"]}) + self.assertEqual([run.hold(str(k)) for k in (1, 2, 3, 4)], + [("perf", ["O", "L"]), ("perf", ["L", "O"]), ("perf", ["O", "L"]), ("quality", ["L", "O"])]) + self.assertEqual(run.hold("5", "2"), ("perf", ["L", "O"])) + self.assertEqual(run.hold("6", "4"), ("quality", ["L", "O"])) + self.assertEqual(run.hold("smoke"), ("perf", ["O", "L"])) + for bad in (("5", None), ("7", None), ("01", None), ("1", "2"), ("5", "5"), ("0", None), ("smoke", "1")): + with self.assertRaises(SystemExit): + run.hold(*bad) + + def test_profiles_name_each_rows_cpu_class(self): + rows = {} + for line in rd(os.path.join(ROOT, "EXPERIMENTS.md")).splitlines(): + if line.startswith("| H59"): + rows[line.split("|")[1].strip()] = line + for host, p in run.PROFILES.items(): + self.assertIn(p["cpu"], rows[p["row"]], host) + self.assertEqual({p["row"] for p in run.PROFILES.values()}, {"H59", "H59d", "H59w"}) + + def test_host_rules(self): + c, d, w = run.PROFILES["crescent"], run.PROFILES["desktop"], run.PROFILES["wildct"] + # N=16 on crescent only; H54's void rule there, H57d's load gate over each arm's idle + # window on the two clients (amendment R2). + self.assertIn("n16", c["phases"].split(",")) + self.assertNotIn("n16", d["phases"].split(",")) + self.assertNotIn("n16", w["phases"].split(",")) + self.assertEqual((c["load_rule"], d["load_rule"], w["load_rule"]), ("h54-void", "h57d-idle", "h57d-idle")) + # Hold-start load1: H54's 12, H57d's 24, wildct's 12; caps: H54's 2400 s, H57d's 4500 s. + self.assertEqual((c["load1_max"], d["load1_max"], w["load1_max"]), (12, 24, 12)) + self.assertEqual((c["cap_s"], d["cap_s"], w["cap_s"]), (2400, 4500, 4500)) + self.assertEqual((c["arms_end_s"], d["arms_end_s"]), (2370, 4470)) + self.assertEqual((c["vendors"], d["vendors"], w["vendors"]), ("amx", "hybrid", "auto,-nvidia,-intel")) + self.assertEqual(w["k3s_args"], run.W3) + self.assertTrue(c["prefix"].startswith("k3dgpu-g-") and d["prefix"].startswith("k3dgpu-d-") + and w["prefix"].startswith("k3dgpu-w-")) + self.assertEqual((c["pcores"], d["pcores"], w["pcores"]), (0, 16, 16)) + + def test_render_env_scrubs_and_pins(self): + shell = {"K3D_GPU_AGENT_BACKEND": "vllm", "K3D_GPU_AGENT_MODEL": "x/y", "K3D_GPU_LLAMA_CPU_IMAGE": "i", + "K3D_GPU_MODEL_OPT": "off", "K3D_GPU_FIT": "off", "K3D_GPU_CONTEXT": "k3d-gpu", "PATH": "/bin", + "K3D_GPU_FIT_HOST": "/h.json", "KUBECONFIG": "/k"} + o = run.render_env("O", shell, "k3dgpu-d-h59-2") + lo = run.render_env("L", shell, "k3dgpu-d-h59-2") + for e in (o, lo): + for k in ("K3D_GPU_AGENT_BACKEND", "K3D_GPU_AGENT_MODEL", "K3D_GPU_LLAMA_CPU_IMAGE", "K3D_GPU_MODEL_OPT", + "K3D_GPU_FIT", "K3D_GPU_CONTEXT"): + self.assertNotIn(k, e) + self.assertEqual(e["K3D_GPU_CLUSTER"], "k3dgpu-d-h59-2") + self.assertEqual((e["K3D_GPU_AGENT_DEVICE"], e["K3D_GPU_TUNE"], e["PATH"], e["KUBECONFIG"]), ("CPU", "off", "/bin", "/k")) + self.assertEqual(o["K3D_GPU_AGENT_REVISION"], run.OVB_REV) + self.assertNotIn("K3D_GPU_AGENT_DRAFT", o) + self.assertEqual((lo["K3D_GPU_AGENT_REVISION"], lo["K3D_GPU_AGENT_DRAFT"]), (run.GGUF_REV, "mtp")) + + def test_docker_argv_runs_the_jobs_container(self): + spec = {"name": "gguf-convert-x", "image": run.LLAMA_FULL_IMAGE, "command": ["bash", "/scripts/convert.sh"], + "args": [run.DRAFT_REPO, run.DRAFT_REV, "/models/" + run.DRAFT_DIR, "4294967296", "Q4_0", "--outtype", "bf16"], + "env": {"GGUF_CONVERT_PIP": "transformers==5.21.0 x==1", "HOME": "/tmp"}, + "resources": {"limits": {"memory": "64Gi"}}} + a = run.docker_argv(spec, "/g/models", "/g/prep/drafter") + self.assertEqual(a[a.index("--memory") + 1], "64g") + self.assertIn("GGUF_CONVERT_PIP=transformers==5.21.0 x==1", a) + self.assertIn("/g/models:/models", a) + self.assertEqual(a[-8:], ["/scripts/convert.sh"] + spec["args"]) + with self.assertRaises(SystemExit): + run.docker_argv(dict(spec, image=run.LLAMA_FULL_IMAGE.split("@")[0].replace("b11960", "b11749")), "/m", "/s") + + +class Render(unittest.TestCase): + """`agent manifest --model` through run.py render, offline, for each host profile.""" + + def setUp(self): + self.box = Box("desktop") + self.job = self.box.prep_job() + self.out = os.path.join(self.box.d, "out") + + def tearDown(self): + self.box.close() + + def docs(self, arm, name="deploy.yaml"): + return run.load_docs(os.path.join(self.out, arm, name)) + + def test_both_arms_render_on_the_pcore_balloon(self): + for arm in ("O", "L"): + r = self.box.render(arm, os.path.join(self.out, arm)) + self.assertEqual(r.returncode, 0, r.stderr) + tpl = run.deployment(self.docs(arm))["spec"]["template"] + c = tpl["spec"]["containers"][0] + self.assertEqual(tpl["metadata"]["labels"]["k3d-gpu.io/cores"], "performance") + self.assertEqual(str(c["resources"]["requests"]["cpu"]), "16") + self.assertEqual(jl(os.path.join(self.out, arm, "check.json"))["problems"], []) + env = rd(os.path.join(self.out, arm, "render-env.txt")) + self.assertIn("K3D_GPU_CLUSTER=k3dgpu-d-h59-1\n", env) + log = rd(os.path.join(self.box.d, "stub", "kubectl.log")) + self.assertIn("ctx=k3d-k3dgpu-d-h59-1 get nodes -l k3d-gpu.io/hybrid-cpu=true -o name", log) + + def test_a_render_that_misses_the_holds_cluster_is_refused(self): + # H57dp's Od deviation: the launcher's context is not the hold's, the node queries fail, + # and the render has no P-core label and 1 CPU. The check stops the arm before apply. + box = Box("desktop", stub_cluster="some-other-cluster") + try: + box.prep_job() + r = box.render("O", os.path.join(box.d, "o")) + self.assertNotEqual(r.returncode, 0) + self.assertIn("not performance (the P-core balloon)", r.stderr) + self.assertIn("CPU request '1'", r.stderr) + finally: + box.close() + + def test_l_stage_drops_only_the_download_and_the_done_conversion(self): + r = self.box.render("L", os.path.join(self.out, "L")) + self.assertEqual(r.returncode, 0, r.stderr) + raw, staged = self.docs("L", "agent.yaml"), self.docs("L") + self.assertEqual(sorted(d["kind"] for d in raw), + ["ConfigMap", "Deployment", "Job", "Namespace", "PersistentVolumeClaim", "Service"]) + self.assertEqual(sorted(d["kind"] for d in staged), ["Deployment", "Namespace", "Service"]) + rc, sc = (run.deployment(x)["spec"]["template"]["spec"]["containers"][0] for x in (raw, staged)) + ra = [str(x) for x in rc["args"]] + i = ra.index("--model-url") + self.assertEqual(sc["args"], ra[:i] + ra[i + 2:]) + self.assertEqual((sc["image"], sc["resources"]), (rc["image"], rc["resources"])) + a = sc["args"] + self.assertEqual(run.value(a, "--model"), run.GGUF_PATH) + self.assertEqual(run.value(a, "--spec-draft-model"), run.DRAFT_PATH) + self.assertEqual(run.value(a, "--spec-type"), "draft-mtp") + self.assertEqual((run.value(a, "--threads"), run.value(a, "--threads-batch")), ("8", "16")) + self.assertTrue(all(m["readOnly"] for m in sc["volumeMounts"] if m["name"] == "models")) + vol = run.deployment(staged)["spec"]["template"]["spec"]["volumes"] + self.assertEqual(vol, [{"name": "models", "hostPath": {"path": "/var/apb-models", "type": "Directory"}}]) + self.assertNotIn("initContainers", run.deployment(staged)["spec"]["template"]["spec"]) + fit = jl(os.path.join(self.out, "L", "fit.json")) + self.assertEqual((fit["threads"], fit["threads-batch"]), ("8", "16")) + self.assertTrue(fit["fit"].startswith("cpu ")) + + def test_o_stage_keeps_the_pull_container(self): + r = self.box.render("O", os.path.join(self.out, "O")) + self.assertEqual(r.returncode, 0, r.stderr) + raw, staged = self.docs("O", "agent.yaml"), self.docs("O") + rs, ss = (run.deployment(x)["spec"]["template"]["spec"] for x in (raw, staged)) + self.assertEqual(ss["initContainers"], rs["initContainers"]) + self.assertEqual(ss["containers"], rs["containers"]) + self.assertEqual(ss["volumes"], [{"name": "models", "hostPath": {"path": "/var/apb-models", "type": "Directory"}}]) + fit = jl(os.path.join(self.out, "O", "fit.json")) + self.assertIsNotNone(fit["max_num_seqs"]) + self.assertFalse(any(x.startswith("--draft") for x in fit["pull_args"])) + + def test_render_needs_prep_s_drafter_job(self): + os.remove(os.path.join(self.box.g, "models", run.DRAFT_DIR, "prep-job.json")) + r = self.box.render("L", os.path.join(self.out, "L")) + self.assertNotEqual(r.returncode, 0) + self.assertIn("no prep-job.json", r.stderr) + put(os.path.join(self.box.g, "models", run.DRAFT_DIR, "prep-job.json"), json.dumps(dict(self.job, image="other"))) + r = self.box.render("L", os.path.join(self.out, "L2")) + self.assertIn("differs from the one prep.sh ran", r.stderr) + + def test_prep_job_is_pr182s_conversion(self): + self.assertEqual(self.job["image"], run.LLAMA_FULL_IMAGE) + self.assertEqual(self.job["args"], [run.DRAFT_REPO, run.DRAFT_REV, "/models/" + run.DRAFT_DIR, "4294967296", + "Q4_0", "--outtype", "bf16"]) + self.assertIn("transformers==5.21.0", self.job["env"]["GGUF_CONVERT_PIP"]) + + def test_check_names_each_deviation(self): + for arm in ("O", "L"): + self.assertEqual(self.box.render(arm, os.path.join(self.out, arm)).returncode, 0) + base = {arm: self.docs(arm) for arm in ("O", "L")} + + def bad(arm, edit): + docs = json.loads(json.dumps(base[arm])) + edit(run.deployment(docs)["spec"]["template"]) + return run.check(arm, "desktop", docs) + + def c(t): + return t["spec"]["containers"][0] + self.assertEqual(run.check("O", "desktop", base["O"]), []) + self.assertEqual(run.check("L", "desktop", base["L"]), []) + self.assertTrue(bad("L", lambda t: c(t).update(image=run.LLAMA_CPU_IMAGE.split("@")[0].replace("b11960", "b11749")))) + self.assertTrue(bad("L", lambda t: c(t)["args"].remove("--spec-type"))) + self.assertTrue(bad("L", lambda t: c(t)["args"].extend(["--model-url", "https://x"]))) + self.assertTrue(bad("L", lambda t: c(t)["args"].__setitem__(c(t)["args"].index("--threads") + 1, "16"))) + self.assertTrue(bad("L", lambda t: [m.pop("readOnly") for m in c(t)["volumeMounts"]])) + self.assertTrue(bad("O", lambda t: t["spec"]["initContainers"][0]["args"].append("--draft_source_model=x"))) + self.assertTrue(bad("O", lambda t: t["metadata"]["labels"].pop("k3d-gpu.io/cores"))) + self.assertTrue(bad("O", lambda t: c(t)["resources"]["requests"].update(cpu="1"))) + self.assertTrue(bad("O", lambda t: c(t)["resources"]["limits"].update({"gpu.intel.com/xe": 1}))) + self.assertTrue(bad("O", lambda t: t["spec"]["initContainers"][0]["args"].remove("--target_device=CPU"))) + # On crescent no hybrid balloon is expected: the P-core label is a deviation there. + self.assertIn("on a host with no hybrid balloon", " ".join(run.check("O", "crescent", base["O"]))) + + +class Crescent(unittest.TestCase): + def test_xeon_render_has_no_balloon(self): + box = Box("crescent", cluster="k3dgpu-g-h59-1") + try: + box.prep_job() + for arm in ("O", "L"): + r = box.render(arm, os.path.join(box.d, arm)) + self.assertEqual(r.returncode, 0, r.stderr) + tpl = run.deployment(run.load_docs(os.path.join(box.d, arm, "deploy.yaml")))["spec"]["template"] + self.assertNotIn("k3d-gpu.io/cores", tpl["metadata"]["labels"]) + finally: + box.close() + + +def phases_line(**k): + return json.dumps(k) + "\n" + + +class Gate(unittest.TestCase): + def setUp(self): + self.d = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.d) + + def arm(self, hold, arm, tries, status="measured"): + ad = os.path.join(self.d, f"hold{hold}", arm) + os.makedirs(ad, exist_ok=True) + put(os.path.join(ad, "status.json"), json.dumps({"status": status})) + with open(os.path.join(ad, "phases.jsonl"), "w") as f: + f.write(phases_line(phase="n1", t0=0, t1=1, check={"void": True, "reason": "n1 does not decide"})) + for i, v in enumerate(tries): + f.write(phases_line(phase="n4", **{"try": i + 1}, t0=1, t1=2, check={"void": v, "reason": f"cpu {v}"})) + + def test_matched_when_every_n4_ends_clean(self): + for k in (1, 2, 3): + self.arm(k, "O", [False]) + self.arm(k, "L", [True, False]) + g = run.gate(self.d) + self.assertTrue(g["matched"], g) + self.assertEqual(g["checked"], 6) + + def test_void_twice_is_unmatched(self): + for k in (1, 2, 3): + self.arm(k, "O", [False]) + self.arm(k, "L", [False]) + self.arm(2, "L", [True, True]) + g = run.gate(self.d) + self.assertFalse(g["matched"]) + self.assertIn("hold 2 L", g["reason"]) + + def test_spare_stands_in_and_unmeasured_arms_are_skipped(self): + for k in (1, 2, 3): + self.arm(k, "O", [False]) + self.arm(k, "L", [False]) + self.arm(3, "L", [True, True]) + self.arm(5, "O", [False]) + self.arm(5, "L", [False]) + put(os.path.join(self.d, "hold5", "repeats"), "3\n") + self.assertTrue(run.gate(self.d)["matched"]) + self.arm(1, "O", [True, True], status="failed") + self.assertTrue(run.gate(self.d)["matched"]) + + def test_missing_check_is_unmatched(self): + for k in (1, 2, 3): + self.arm(k, "O", [False]) + self.arm(k, "L", [False]) + with open(os.path.join(self.d, "hold1", "O", "phases.jsonl"), "w") as f: + f.write(phases_line(phase="n4", t0=1, t1=2)) + self.assertFalse(run.gate(self.d)["matched"]) + + +class IdleGate(unittest.TestCase): + """H59d's and H59w's load gate (amendment R2): H57d's gate over each arm's mean foreign + P-core load in its 60 s idle windows; the N=4 reading is recorded beside it, not gated.""" + + def setUp(self): + self.d = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.d) + + def hold(self, k, idle, n4, status=None): + """idle, n4: {arm: foreign P-core CPU-s/s} for the hold; each arm's idle window then its + N=4 phase, one after the other, with H57d's sampler records over both.""" + hd = os.path.join(self.d, f"hold{k}") + samples, t = [], 1000.0 + for a in ("O", "L"): + ad = os.path.join(hd, a) + put(os.path.join(ad, "status.json"), json.dumps({"status": (status or {}).get(a, "measured")})) + with open(os.path.join(ad, "phases.jsonl"), "w") as f: + for ph, load in (("idle", idle[a]), ("n4", n4[a])): + f.write(phases_line(phase=ph, t0=t, t1=t + 60)) + samples += [{"ts": t + s, "seconds": 5, "pcore_busy": 8.0 + load, "node_cpu": 8.0} + for s in range(5, 61, 5)] + t += 70 + put(os.path.join(hd, "load.jsonl"), "".join(json.dumps(s) + "\n" for s in samples)) + + def test_idle_windows_gate_and_n4_is_recorded_only(self): + for k in (1, 2, 3): # idle alike, N=4 apart (the H57d pattern): matched + self.hold(k, {"O": 5.0, "L": 6.0}, {"O": 2.9, "L": 5.6}) + g = run.idle_gate(self.d) + self.assertTrue(g["matched"], g) + self.assertAlmostEqual(g["spread"], 1.0) + self.assertEqual(g["means"], {"L": 6.0, "O": 5.0}) + self.assertIn("idle window", g["rule"]) + self.assertFalse(g["n4"]["matched"]) + self.assertAlmostEqual(g["n4"]["spread"], 2.7) + self.assertFalse(g["n4"]["gates"]) + + def test_idle_windows_apart_is_unmatched_even_when_n4_matches(self): + for k in (1, 2, 3): + self.hold(k, {"O": 1.0, "L": 4.5}, {"O": 3.0, "L": 3.0}) + g = run.idle_gate(self.d) + self.assertFalse(g["matched"]) + self.assertIn("3.50 > 2.00", g["reason"]) + self.assertTrue(g["n4"]["matched"]) + + def test_spare_stands_in_unmeasured_skipped_missing_window_unmatched(self): + for k in (1, 2, 3): + self.hold(k, {"O": 5.0, "L": 5.0}, {"O": 5.0, "L": 5.0}) + self.hold(2, {"O": 1.0, "L": 9.0}, {"O": 5.0, "L": 5.0}) + self.hold(5, {"O": 5.0, "L": 5.5}, {"O": 5.0, "L": 5.0}) + put(os.path.join(self.d, "hold5", "repeats"), "2\n") + self.assertTrue(run.idle_gate(self.d)["matched"]) + self.hold(3, {"O": 5.0, "L": 20.0}, {"O": 5.0, "L": 5.0}, status={"L": "failed"}) + self.assertTrue(run.idle_gate(self.d)["matched"]) + for k in (1, 2, 3, 5): # no idle window recorded for L: no reading, unmatched + p = os.path.join(self.d, f"hold{k}", "L", "phases.jsonl") + put(p, "".join(x + "\n" for x in rd(p).splitlines() if json.loads(x)["phase"] != "idle")) + g = run.idle_gate(self.d) + self.assertFalse(g["matched"]) + self.assertIsNone(g["means"]["L"]) + + def test_arm_names_and_the_cli(self): + for k in (1, 2, 3): + self.hold(k, {"O": 5.0, "L": 6.0}, {"O": 2.9, "L": 5.6}) + out = subprocess.run([sys.executable, os.path.join(HERE, "run.py"), "idle-gate", self.d], + capture_output=True, text=True) + self.assertEqual(out.returncode, 0, out.stderr) + self.assertTrue(json.loads(out.stdout)["matched"]) + g = run.idle_gate(self.d, {"L": "L", "O": "O"}) + self.assertEqual(sorted(g["means"]), ["L", "O"]) + + def test_the_desktop_records_the_amendment_cites(self): + """H57d (2026-10-21, Ld-mtp and Od, H59's pair): idle spread 1.46, N=4 spread 2.61. + H57dp (2026-10-29, three arms): idle spread 0.21, N=4 spread 2.70. Limit 2.00.""" + res = os.path.join(ROOT, "bench", "h57d", "results") + g = run.idle_gate(os.path.join(res, "2026-10-21"), {"L": "Ld-mtp", "O": "Od"}) + self.assertTrue(g["matched"], g) + self.assertEqual((round(g["spread"], 2), g["tolerance"]), (1.46, 2.0)) + self.assertFalse(g["n4"]["matched"]) + self.assertEqual(round(g["n4"]["spread"], 2), 2.61) + g = run.idle_gate(os.path.join(res, "2026-10-29"), {"a": "Ld-mtp", "b": "Ld-mtp-u", "c": "Od"}) + self.assertTrue(g["matched"], g) + self.assertEqual(round(g["spread"], 2), 0.21) + self.assertFalse(g["n4"]["matched"]) + self.assertEqual(round(g["n4"]["spread"], 2), 2.70) + + +class Prefix(unittest.TestCase): + def test_six_agents_one_shared_prefix(self): + srv = serve({"v1": 0.0, "v3": 0.0}) + out = os.path.join(tempfile.mkdtemp(), "prefix.json") + try: + rec = run.prefix(f"http://127.0.0.1:{srv.server_address[1]}/v1", "gemma", 5901, out) + finally: + srv.shutdown() + srv.server_close() + bodies = [b for _, b in srv.bodies] + self.assertEqual(len(bodies), 6) + self.assertEqual(len({json.dumps(b["messages"][0]) for b in bodies}), 1) + self.assertEqual(len({json.dumps(b["tools"]) for b in bodies}), 1) + self.assertEqual(len({b["messages"][1]["content"] for b in bodies}), 6) + for b in bodies: + self.assertEqual((b["model"], b["max_tokens"], b["stream"]), ("gemma", 8, True)) + self.assertEqual(b["chat_template_kwargs"], {"enable_thinking": False}) + self.assertEqual([a["agent"] for a in rec["agents"]], ["a1", "a2", "a3", "a4", "a5", "a6"]) + self.assertIsNotNone(rec["added_ttft_median_s"]) + self.assertEqual(jl(out)["seed"], 5901) + + +def run_arm(box, arm, hold_dir, mode, holdnum, port, **extra): + env = dict(box.env, MODE=mode, DEADLINE=str(int(time.time()) + 5000), HOLDNUM=str(holdnum), H59_PORT=str(port)) + env.update(extra) + r = subprocess.run(["bash", os.path.join(HERE, "arm.sh"), arm, os.path.join(hold_dir, arm)], env=env, + capture_output=True, text=True, timeout=600) + return r + + +def synth_load(hold_dir, arms, foreign=1.0): + """H57d's sampler records over the hold's phases: P-core busy and node CPU, matched.""" + t = [] + for a in arms: + for line in rd(os.path.join(hold_dir, a, "phases.jsonl")).splitlines(): + r = json.loads(line) + t += [r["t0"], r["t1"]] + with open(os.path.join(hold_dir, "load.jsonl"), "w") as f: + ts = min(t) - 1 + while ts < max(t) + 1: + f.write(json.dumps({"ts": ts, "seconds": 0.2, "pcore_busy": 6.0 + foreign, "node_cpu": 6.0}) + "\n") + ts += 0.2 + + +class EndToEnd(unittest.TestCase): + """arm.sh, as hold-run.sh runs it, for the registered holds of a desktop row: three + performance holds in their orders and the quality hold; then h59.py scores the records.""" + + def test_holds_feed_the_scorer(self): + box = Box("desktop") + srv = serve({"v1": 0.0008, "v3": 0.0016}) # L decodes about twice as fast as O + try: + box.prep_job() + res = os.path.join(box.g, "results", "H59d") + for k in (1, 2, 3, 4): + mode, arms = run.hold(str(k)) + hd = os.path.join(res, f"hold{k}") + for a in arms: + r = run_arm(box, a, hd, mode, k, srv.server_address[1], CLUSTER=box.cluster) + st = jl(os.path.join(hd, a, "status.json")) + self.assertEqual(st["status"], "measured", f"hold {k} {a}: {st} {r.stderr[-2000:]}") + if mode == "perf": + synth_load(hd, arms) + for k in (1, 2, 3): + ad = os.path.join(res, f"hold{k}", "L") + for f in ("load-n1.json", "load-n4.json", "load-ttft.json", "prefix.json", "cpus.txt", "fit.json", + "deploy.yaml", "energy-n4.json", "energy-idle.json"): + self.assertTrue(os.path.exists(os.path.join(ad, f)), f"hold {k} L {f}") + self.assertFalse(os.path.exists(os.path.join(ad, "load-n16.json"))) + n4 = jl(os.path.join(ad, "load-n4.json"))["phases"][0] + self.assertEqual((n4["concurrency"], len(n4["samples"])), (4, 16)) + self.assertEqual([json.loads(x)["phase"] for x in rd(os.path.join(ad, "phases.jsonl")).splitlines()], + ["idle", "n1", "n4", "ttft", "prefix"]) + q = jl(os.path.join(res, "hold4", "O", "quality.json")) + self.assertEqual((q["n"], q["errors"]), (50, 0)) + self.registered_requests(res, srv.bodies) + # The desktop's load gate reads each arm's idle window (amendment R2). + rep = h59.score(res, run.idle_gate(res)) + self.assertEqual(rep["verdict"], "SUPPORTED", json.dumps({k: v for k, v in rep.items() if k != "arms"})) + self.assertEqual([rep["arms"][a]["n4_requests"] for a in ("L", "O")], [48, 48]) + self.assertGreater(rep["ci"][0], 1.2) + self.assertTrue(rep["all_holds_faster"]) + self.assertTrue(rep["load_gate"]["matched"]) + self.assertIn("idle window", rep["load_gate"]["rule"]) + # The CLIs on the same records: run.py idle-gate writes GATE.json, h59.py reads it. + g = subprocess.run([sys.executable, os.path.join(HERE, "run.py"), "idle-gate", res], + capture_output=True, text=True) + self.assertEqual(g.returncode, 0, g.stderr) + gate = os.path.join(res, "gate.json") + put(gate, g.stdout) + cli = subprocess.run([sys.executable, os.path.join(HERE, "h59.py"), "score", res, "--load", gate], + capture_output=True, text=True) + self.assertEqual(json.loads(cli.stdout)["verdict"], "SUPPORTED") + finally: + srv.shutdown() + srv.server_close() + box.close() + + def registered_requests(self, res, bodies): + """The requests as registered (H59 pre-registration, Measurement 1-3), per arm: in each + performance hold 3 warmup, 8 N=1 and 16 N=4 requests streamed at max_tokens 256 with + ignore_eos and temperature 0, 4 TTFT requests on H54's prompt at max_tokens 16, 6 prefix + agents at max_tokens 8; GSM8K items 0-49 at max_tokens 1024, temperature 0, thinking off. + Every load prompt is unique; each arm's boot limit is 900 s.""" + ttft = rd(os.path.join(ROOT, "bench", "h54", "ttft-prompt.txt")).rstrip("\n") + for k in (1, 2, 3): + for arm in ("L", "O"): + ad = os.path.join(res, f"hold{k}", arm) + for ph, n, c in (("warmup", 3, 1), ("n1", 8, 1), ("n4", 16, 4), ("ttft", 4, 1)): + rec = jl(os.path.join(ad, f"load-{ph}.json"))["phases"][0] + self.assertEqual((rec["concurrency"], len(rec["samples"])), (c, n), f"hold {k} {arm} {ph}") + self.assertEqual(jl(os.path.join(ad, "boot.json"))["limit_s"], 900) + for api, arm in (("v1", "L"), ("v3", "O")): + b = [x for p, x in bodies if p.split("/")[1] == api] + load = [x for x in b if x.get("stream") and "tools" not in x] + gen = [x for x in load if x["max_tokens"] == 256] + tt = [x for x in load if x["max_tokens"] == 16] + self.assertEqual((len(gen), len(tt), len(load)), (3 * 27, 3 * 4, 3 * 31), arm) + self.assertTrue(all(x.get("ignore_eos") is True and x["temperature"] == 0 for x in gen), arm) + self.assertTrue(all(x["temperature"] == 0 and x["messages"][0]["content"].endswith(ttft) for x in tt), arm) + prompts = [x["messages"][0]["content"] for x in load] + self.assertEqual(len(set(prompts)), len(prompts), arm) + pre = [x for x in b if "tools" in x] + self.assertEqual((len(pre), {x["max_tokens"] for x in pre}), (3 * 6, {8}), arm) + gsm = [x for x in b if not x.get("stream") and x.get("max_tokens") == 1024] + self.assertEqual(len(gsm), 50, arm) + self.assertTrue(all(x["temperature"] == 0 and x["chat_template_kwargs"] == {"enable_thinking": False} + for x in gsm), arm) + + def test_crescent_n16_and_the_void_rerun(self): + box = Box("crescent", cluster="k3dgpu-g-h59-1") + srv = serve({"v1": 0.0004, "v3": 0.0004}) + try: + box.prep_job() + serving = os.path.join(box.d, "serving.jsonl") + now = time.time() + with open(serving, "w") as f: # non-campaign CPU 9 CPU-s/s throughout: every phase is void + for i in range(-20, 900): + f.write(json.dumps({"ts": now + i, "seconds": 1, "serving_cpu": 9.0, "aiperf_procs": 0}) + "\n") + hd = os.path.join(box.g, "results", "H59", "hold1") + r = run_arm(box, "L", hd, "perf", 1, srv.server_address[1], SERVING=serving) + st = jl(os.path.join(hd, "L", "status.json")) + self.assertEqual(st["status"], "measured", r.stderr[-2000:]) + ph = [json.loads(x) for x in rd(os.path.join(hd, "L", "phases.jsonl")).splitlines()] + self.assertEqual([p["phase"] for p in ph if p["phase"] == "n4"], ["n4", "n4"]) + self.assertTrue(all(p["check"]["void"] for p in ph if p["phase"] == "n4")) + for f in ("load-n16.json", "load-n4-void1.json", "energy-idle-n4.json"): + self.assertTrue(os.path.exists(os.path.join(hd, "L", f)), f) + n16 = jl(os.path.join(hd, "L", "load-n16.json"))["phases"][0] + self.assertEqual((n16["concurrency"], len(n16["samples"])), (16, 32)) + g = run.gate(os.path.join(box.g, "results", "H59")) + self.assertFalse(g["matched"]) + self.assertIn("hold 1 L", g["reason"]) + finally: + srv.shutdown() + srv.server_close() + box.close() + + def test_render_refusal_and_not_reached(self): + box = Box("desktop", stub_cluster="elsewhere") + try: + box.prep_job() + hd = os.path.join(box.d, "hold1") + run_arm(box, "O", hd, "perf", 1, 1) + st = jl(os.path.join(hd, "O", "status.json")) + self.assertEqual(st["status"], "failed") + self.assertIn("render not the registered arm", st["reason"]) + self.assertFalse(os.path.exists(os.path.join(box.d, "stub", "applied.yaml"))) + r = run_arm(box, "O", os.path.join(box.d, "hold2"), "perf", 2, 1, DEADLINE=str(int(time.time()) + 100)) + st = jl(os.path.join(box.d, "hold2", "O", "status.json")) + self.assertEqual(st["status"], "not_reached", r.stderr) + finally: + box.close() + + +class HoldGuards(unittest.TestCase): + """hold.sh refuses, before the lock, what would spend a hold on the wrong thing.""" + + def setUp(self): + self.d = tempfile.mkdtemp() + self.g = os.path.join(self.d, "g") + put(os.path.join(self.d, "cpuinfo"), "processor\t: 0\nmodel name\t: 13th Gen Intel(R) Core(TM) i9-13900T\n") + os.makedirs(os.path.join(self.g, "bin")) + shutil.copy(os.path.join(HERE, "k3d-shim.sh"), os.path.join(self.g, "bin", "k3d")) + m = os.path.join(self.g, "models") + for p in run.models_ready(self.g): + put(p, "x") + self.assertEqual(run.models_ready(self.g), []) + # flock stands in so a hold that passes every guard stops at the lock (exit 4). + put(os.path.join(self.d, "bin", "flock"), "#!/bin/sh\nexit 1\n", 0o755) + self.env = {"PATH": os.path.join(self.d, "bin") + ":/usr/bin:/bin", "HOME": self.d, "G": self.g, + "H59_IMAGE": NODE_IMAGE, "H59_CPUINFO": os.path.join(self.d, "cpuinfo"), + "LOCK": os.path.join(self.d, "lock")} + self.res = os.path.join(self.g, "results", "H59d") + self.m = m + + def tearDown(self): + shutil.rmtree(self.d) + + def hold(self, *args, **env): + return subprocess.run(["bash", os.path.join(HERE, "hold.sh"), *args], env=dict(self.env, **env), + capture_output=True, text=True, timeout=60) + + def ran(self, k, statuses): + hd = os.path.join(self.res, f"hold{k}") + put(os.path.join(hd, "rc"), "0\n") + for a, s in statuses.items(): + put(os.path.join(hd, a, "status.json"), json.dumps({"status": s})) + + def test_guards_pass_to_the_lock(self): + r = self.hold("desktop", "1") + self.assertEqual(r.returncode, 4, r.stderr) + + def test_wrong_cpu_class(self): + r = self.hold("wildct", "1") + self.assertEqual(r.returncode, 2) + self.assertIn("not the i7-13700", r.stderr) + + def test_cpu_class_is_the_exact_model_number(self): + # The optimizer matches a row's CPU class as an exact token (console/model_opt.py + # class_tokens), so a sibling SKU whose name only contains the row's number is another class. + for name, host, ok in (("13th Gen Intel(R) Core(TM) i7-13700K", "wildct", False), + ("13th Gen Intel(R) Core(TM) i7-13700", "wildct", True), + ("13th Gen Intel(R) Core(TM) i9-13900TE", "desktop", False), + ("13th Gen Intel(R) Core(TM) i9-13900T", "desktop", True), + ("Intel(R) Xeon(R) 6741P", "crescent", True), + ("Intel(R) Xeon(R) 6741P-B", "crescent", False)): + put(os.path.join(self.d, "cpuinfo"), f"processor\t: 0\nmodel name\t: {name}\n") + r = self.hold(host, "1") + self.assertEqual(r.returncode, 4 if ok else 2, (name, r.stderr)) + + def test_unregistered_holds(self): + for args in (("desktop", "7"), ("desktop", "5"), ("desktop", "01"), ("desktop", "1", "2"), ("nowhere", "1")): + self.assertEqual(self.hold(*args).returncode, 2, args) + + def test_image_pinned_by_digest(self): + self.assertEqual(self.hold("desktop", "1", H59_IMAGE=NODE_IMAGE.split("@")[0]).returncode, 2) + + def test_shim_and_models(self): + os.remove(os.path.join(self.g, "bin", "k3d")) + self.assertIn("k3d-shim.sh", self.hold("desktop", "1").stderr) + shutil.copy(os.path.join(HERE, "k3d-shim.sh"), os.path.join(self.g, "bin", "k3d")) + os.remove(os.path.join(self.m, run.DRAFT_DIR, "prep-job.json")) + r = self.hold("desktop", "1") + self.assertEqual(r.returncode, 2) + self.assertIn("prep.sh", r.stderr) + + def test_a_hold_runs_once(self): + self.ran(2, {"L": "measured", "O": "failed"}) + r = self.hold("desktop", "2") + self.assertEqual(r.returncode, 2) + self.assertIn("a failed hold counts", r.stderr) + + def test_spares_repeat_a_failed_hold_only(self): + self.assertEqual(self.hold("desktop", "5", "2").returncode, 2) # hold 2 has not run + self.ran(2, {"L": "measured", "O": "measured"}) + self.assertEqual(self.hold("desktop", "5", "2").returncode, 2) # nothing failed in it + self.ran(3, {"O": "measured", "L": "not_deliverable"}) + self.assertEqual(self.hold("desktop", "5", "3").returncode, 4) # passes to the lock + self.assertFalse(os.path.exists(os.path.join(self.res, "hold5", "repeats"))) + + def test_spare_six_follows_spare_five_and_reads_its_record(self): + # h59.py reads spare 6 over spare 5 for the same hold, so spare 6 runs only after spare 5, + # and a spare repeats a hold only while the record standing for it has an arm not measured. + self.ran(1, {"O": "measured", "L": "failed"}) + self.assertEqual(self.hold("desktop", "6", "1").returncode, 2) # spare 5 has not run + self.ran(5, {"O": "measured", "L": "measured"}) + put(os.path.join(self.res, "hold5", "repeats"), "1\n") + r = self.hold("desktop", "6", "1") + self.assertEqual(r.returncode, 2, r.stderr) # spare 5 measured hold 1 + self.assertIn("spare 5", r.stderr) + self.ran(5, {"O": "measured", "L": "failed"}) + self.assertEqual(self.hold("desktop", "6", "1").returncode, 4) # spare 5 failed too + self.ran(2, {"O": "not_deliverable", "L": "measured"}) + self.assertEqual(self.hold("desktop", "6", "2").returncode, 4) # spare 5 repeated hold 1 + + def test_an_interrupted_hold_counts(self): + # A hold killed after it started records no rc; it still ran, so it is not run again and a + # spare may repeat it. + hd = os.path.join(self.res, "hold3") + put(os.path.join(hd, "started"), "2026-10-29T00:00:00Z\n") + put(os.path.join(hd, "O", "status.json"), json.dumps({"status": "measured"})) + r = self.hold("desktop", "3") + self.assertEqual(r.returncode, 2) + self.assertIn("interrupted or not", r.stderr) + self.assertEqual(self.hold("desktop", "5", "3").returncode, 4) + + def test_node_image_is_the_rows(self): + put(os.path.join(self.res, "node-image"), "cryptoandcoffee/k3d-gpu:0.1.79@sha256:" + "b" * 64 + "\n") + r = self.hold("desktop", "1") + self.assertEqual(r.returncode, 2) + self.assertIn("node image", r.stderr) + + +class Shim(unittest.TestCase): + def test_volume_only_on_this_campaigns_clusters(self): + d = tempfile.mkdtemp() + try: + put(os.path.join(d, "k3d"), "#!/bin/sh\necho \"$@\"\n", 0o755) + env = {"PATH": "/usr/bin:/bin", "K3D_REAL": os.path.join(d, "k3d"), "H59_G": "/g", "H59_PREFIX": "k3dgpu-d-h59-"} + shim = os.path.join(HERE, "k3d-shim.sh") + out = subprocess.run(["bash", shim, "cluster", "create", "k3dgpu-d-h59-1", "--x"], env=env, + capture_output=True, text=True).stdout + self.assertEqual(out.strip(), "cluster create k3dgpu-d-h59-1 --x --volume=/g/models:/var/apb-models@server:*") + out = subprocess.run(["bash", shim, "cluster", "create", "k3dgpu-d-h57d-1"], env=env, capture_output=True, text=True).stdout + self.assertEqual(out.strip(), "cluster create k3dgpu-d-h57d-1") + finally: + shutil.rmtree(d) + + +if __name__ == "__main__": + unittest.main() diff --git a/bench/wildct/RESUME.md b/bench/wildct/RESUME.md index 9aae1bd7..87d4b195 100644 --- a/bench/wildct/RESUME.md +++ b/bench/wildct/RESUME.md @@ -1,44 +1,45 @@ # wildct resume pack How to resume the wildct campaign ([EXPERIMENTS.md](../../EXPERIMENTS.md), "Wildct -pre-registration", rules W1-W8, and wildct amendments R1, P1, R2 and R3) once the box owner has -made the kernel modules k3s needs available on wildct (step 1, H9 and H10). This file changes no -rule. Run it top to bottom, except that step 3b (ship `origin/main` to wildct) runs first when the -`repo` on wildct predates `bench/wildct/nfprobe.sh` (H10 runs it). Nothing past step 1 runs until -every step 1 check passes. - -## Where things stand (2026-10-29, after `smoke3`) - -- **Blocked: k3s still cannot run on wildct.** The box owner loaded `vxlan` and `br_netfilter` - on 2026-10-29 and kept them across reboots with `/etc/modules-load.d/k3dgpu-wildct.conf` - (`kernel.modprobe` left empty; the production checks read the same before and after: - `results/2026-10-29/host/modules.txt`). Step 1 then passed (H9 included), main at `c4407a2c` - was shipped (step 3b), and the wiring smoke `smoke3` printed every pass marker of step 5. The - cluster was not working: k3s's network policy controller stopped every ~16 s on "Extension - comment revision 0 not supported, missing kernel module?", the node container restarted, the - node carried the `not-ready` taint, and coredns, metrics-server and the NFD pods stayed 0/1 - (`pods.txt`: 10 restarts each). The old pod wait read STATUS only, so it said `yes`. `smoke3` - is discarded (amendment R3); the pod wait now runs `bench/wildct/ready.sh` (every node Ready, - every container ready). -- **What the kernel still lacks.** `bench/wildct/nfprobe.sh` (step 1's H10) adds, in a throwaway - container, one rule per iptables extension and one set per ipset type in its list, with the node - image's own iptables-nft and ipset. On 2026-10-29 it read - `NFPROBE missing: NFLOG REJECT comment hash:ip hash:ip,port hash:ip,port,ip hash:ip,port,net - hash:net limit multiport physdev pkttype statistic` (`results/2026-10-29/runs/nfprobe.txt`). - Every module behind them is on disk for 6.6.156-2-MANJARO and not loaded. The fix is the box - owner's, either of: - - restore `kernel.modprobe` (`/sbin/modprobe`; the kernel then loads what a rule asks for); - - load the modules and add them to `/etc/modules-load.d/k3dgpu-wildct.conf`: - `modprobe -a xt_comment xt_statistic xt_multiport xt_limit xt_NFLOG xt_physdev xt_pkttype - ipt_REJECT ip6t_REJECT ip_set_hash_ip ip_set_hash_net ip_set_hash_ipport ip_set_hash_ipportip - ip_set_hash_ipportnet`. - - Then step 3b (the `repo` on wildct is `c4407a2c`, without `nfprobe.sh` and `ready.sh`), step 1 - (H10 must read `NFPROBE clean`), and the smoke again, as `smoke4`. +pre-registration", rules W1-W8, and wildct amendments R1, P1, R2, R3, R4 and R5). This file +changes no rule. Run it top to bottom, except that step 3b (ship `origin/main` to wildct) runs +first when the `repo` on wildct predates `bench/wildct/nfprobe.sh` (H10 runs it). Nothing past +step 1 runs until every step 1 check passes. + +## Where things stand (2026-10-30, after smoke4) + +- **Ready for the first hold, `H52w` (step 6, row 1).** The kernel modules k3s needs are loaded + and persisted, step 1 passes on release 0.1.81, and the wiring smoke `smoke4` passed. No hold + has run; nothing has been measured. +- **Kernel modules** (each approved by the box owner on 2026-10-29; `kernel.modprobe` stays empty): + - `vxlan` and `br_netfilter` (amendment R3; `results/2026-10-29/host/modules.txt`); + - `xt_comment xt_statistic xt_multiport xt_limit xt_NFLOG xt_physdev xt_pkttype ipt_REJECT + ip6t_REJECT ip_set_hash_ip ip_set_hash_net ip_set_hash_ipport ip_set_hash_ipportip + ip_set_hash_ipportnet`, with three hard dependencies (`nfnetlink_log`, `nf_reject_ipv4`, + `nf_reject_ipv6`), loaded at 2026-10-29T21:16:54Z (amendment R4; + `results/2026-10-29/host/modules-netfilter.txt`); + - `nft_limit` and `nft_log` (iptables-nft writes `-m limit` and `-j NFLOG` as nftables' native + `limit` and `log` expressions), loaded at 2026-10-30T05:02:48Z under the box owner's approval + of the netfilter module class (`nf_`, `nft_`, `xt_`, `ipt_`, `ip6t_`, `ip_set`), each load + with the same snapshot, check and rollback procedure (amendment R5; + `results/2026-10-30/host/modules-nft.txt`). + + All 18 are listed in `/etc/modules-load.d/k3dgpu-wildct.conf` and loaded. Each load left the + host's containers, published endpoints and firewall rule counts as they were. If a later probe + names another missing module of that class, load it the same way: snapshot, `modprobe -a`, + the same checks at once and 3 minutes later, `modprobe -r` if anything changed, then append it + to the file. +- **2026-10-30 (amendment R5; `results/2026-10-30/`).** Main at `cf49f0e6` was shipped (step 3b) + and 0.1.81 installed (step 3c). Step 1 passed at 05:09:52Z (`runs/resume-health-r5.txt`: H9 + `br_netfilter vxlan`, H10 `NFPROBE clean` with the 0.1.81 node image). `smoke4` (05:10:53Z, + rc 0): `up rc=0 in 30 s`, `pods ready: yes after 16 s`, `GPU-SCAN clean`, `down rc=0`, nvidia + and intel opted out, the node container `devices=[] gpus=[] restarts=0`, every pod 1/1 + Running with 0 restarts or Completed, no GPU plugin pod, no `/dev` hostPath, no GPU resource in + the node's allocatable. No cluster, container or process of the campaign was left after it. - **Left on wildct by this campaign** (no container, cluster, network or volume): - `~/k3dgpu-bench-wildct/`, which holds: - `bin/`: k3d v5.9.0, helm v4.3.1, kubectl v1.37.2; - - `k3d-gpu/`: the 0.1.79 `.deb`, unpacked; + - `k3d-gpu/`: the 0.1.81 `.deb`, unpacked; - `hfvenv/`; - `models/` and `h57/models/`: every file sha256-checked against the Hub, `VERIFY ok` (`setup/dl.log` also holds the first H57 verify's `VERIFY 1 bad`: the mmproj file, @@ -47,33 +48,39 @@ every step 1 check passes. `8eb21d5b...`, 321,145,696 bytes, the i9's bytes) and Od's `graph.pbtxt` (`c57ecf35...`, the i9's); - `h57/bin/k3d` (the shim) and `h57/weights.sha256`; - - `repo` -> `repo-c4407a2c2ba8` (and the older archives); + - `repo` -> `repo-cf49f0e6555e` (and the older archives); - `setup/`: `images-before.txt`, `containers-before.txt`, `volumes-before.txt`, `networks-before.txt` and the verify logs; - `runs/`: `smoke1*`, `smoke2*`, `diag-smoke2*`, `smoke3*` (with `smoke3-node.log`, the - node's log, and `smoke3-node-excerpt.txt`), `convert/`, `resume-health.txt` (and the - earlier one, renamed `resume-health.txt.`), `install-0.1.79.log`, + node's log, and `smoke3-node-excerpt.txt`), `smoke4*` (with `smoke4-live.txt`), `convert/`, + `resume-health.txt` (2026-10-30T05:09:52Z; the earlier ones renamed + `resume-health.txt.`), `install-0.1.79.log`, `install-0.1.81.log`, `setup-images-*.log`, `IMAGE` and `campaign.log`; - - `diag/diag-up.sh`, the command `diag-smoke2` ran, and `diag/iptprobe-in.sh`, an ad-hoc - form of `nfprobe.sh`. - - `/etc/modules-load.d/k3dgpu-wildct.conf` (root, 0644), written with the box owner's approval - on 2026-10-29. It is the owner's: step 9 does not remove it. - - Images: `cryptoandcoffee/k3d-gpu:0.1.79`, `ghcr.io/k3d-io/k3d-tools:5.9.0`, the five refs of + - `diag/`: `diag-up.sh`, the command `diag-smoke2` ran; `iptprobe-in.sh`, an ad-hoc form of + `nfprobe.sh`; the second load's read-only checks `modsnap.sh` (modules and firewall, as + root), `prodcheck.sh` (the host's services), `health.sh` (step 1, as written below) and + `nftexpr.sh` (`runs/nftexpr.txt`), with `modules-append.conf`, the lines that load + appended; and 2026-10-30's `smoke4-live.sh` (a read-only view of the smoke cluster) and + `modstate.sh` (the loaded modules and firewall line counts, as root). + - `/etc/modules-load.d/k3dgpu-wildct.conf` (root, 0644; 18 modules), written with the box + owner's approval. It is the owner's: step 9 does not remove it. + - Images: `cryptoandcoffee/k3d-gpu:0.1.81`, `ghcr.io/k3d-io/k3d-tools:5.9.0`, the five refs of `setup-images.sh`, and `python:3.13-slim@sha256:70729b46...` (the RAPL reader's image until - 2026-10-29). `cryptoandcoffee/k3d-gpu:0.1.71` was removed in step 3c. -- **Never existed.** No inference request. No measurement. `smoke1`, `smoke2`, `diag-smoke2` - and `smoke3` are discarded and disclosed (amendments R1, R2 and R3). + 2026-10-29). `cryptoandcoffee/k3d-gpu:0.1.71` and `:0.1.79` were removed in step 3c. +- **Never existed.** No inference request. No measurement. `smoke1`, `smoke2`, `diag-smoke2`, + `smoke3` and `smoke4` are discarded and disclosed (amendments R1, R2, R3 and R5); `smoke4` is + the campaign's passing wiring smoke. - **Never touched.** No GPU, nor anything that was on the host before the campaign, apart from - the two kernel modules and their `modules-load.d` file, which the box owner approved. -- **Newest release on 2026-10-29: k3d-gpu 0.1.79**, installed in step 3c. Step 3a picks the newest + the 18 kernel modules (and their three dependencies) and their `modules-load.d` file, which the + box owner approved. +- **Newest release on 2026-10-30: k3d-gpu 0.1.81**, installed in step 3c. Step 3a picks the newest one again. - - `.deb` sha256: `3c94881c20ddaca11b1422044f62e2a2b1f01e8b6bb698d4d99e1bb5763830fb`. - - Node image: `cryptoandcoffee/k3d-gpu:0.1.79@sha256:6e27ff9817bf9398edb20f479dceb5f08273a04a01971b37628a7b2791bdfcfe`. - - Its launcher and `share/` equal main's at `4a322ccc` and `c4407a2c`, apart from the version - line. -- **H57wb's start gates were met on 2026-10-29** (`/` 208 GB available, MemAvailable 118 GiB), - not a reading of the row. If they still are at step 6's row 7, the pack stops there: H57wb has - no runner on this host. + - `.deb` sha256: `beac5c26ef33bbe784c4cd42ff4c18b9588534df9189c284c93bfd57ed85fca3`. + - Node image: `cryptoandcoffee/k3d-gpu:0.1.81@sha256:b68387c9c73ff57ab19facfa18196519a14df31576cdfb0d225f6f9401d2380e`. + - Its launcher and `share/` equal main's at `cf49f0e6`, apart from the version line. +- **H57wb's start gates were met on 2026-10-30** (`/` 208 GB available, MemAvailable 118 GiB, at + step 1), not a reading of the row. If they still are at step 6's row 7, the pack stops there: + H57wb has no runner on this host. ## Ground rules (W1-W8; this pack adds none) @@ -133,7 +140,7 @@ Run them as one script (`timeout 600 $WSSH bash -s <<'EOF' ... EOF`). Every chec | H7 | `timeout 60 k3d cluster list -o json \| python3 $R/bench/wildct/clusters.py` | empty | a `k3dgpu-w-*` cluster exists: report (step 2 does not delete clusters) | | H8 | `flock -n $W/live.lock true && echo lock-free; ps -eo pid,args \| grep '[h]old.sh'` | `lock-free`, no `hold.sh` | a hold is still running | | H9 | `awk '$1 == "vxlan" \|\| $1 == "br_netfilter" {print $1}' /proc/modules \| sort \| paste -sd' '; echo "modprobe=[$(cat /proc/sys/kernel/modprobe)]"` | `br_netfilter vxlan` (both loaded; record `modprobe=`) | `vxlan` missing: k3s's flannel cannot create its VXLAN device and k3s exits (`smoke2`, 2026-10-29, with `modprobe=[]`: the kernel cannot load it on demand). `br_netfilter` missing: k3s cannot set `net.bridge.bridge-nf-call-iptables`, which Service traffic between pods on one node needs. Either: report to the box owner | -| H10 | `timeout 300 bash $R/bench/wildct/nfprobe.sh "$(cat $W/runs/IMAGE)"; echo "nfprobe rc=$?"` (a throwaway container from the release node image: `--network none`, NET_ADMIN and NET_RAW only, CPUs 20-23; adds one rule per iptables extension in its list, in its own network namespace, and one ipset per set type in its list) | `NFPROBE clean`, `nfprobe rc=0` | `NFPROBE missing: ...`: k3s's iptables rules fail (`smoke3`, 2026-10-29: the network policy controller stopped on `comment` and k3s restarted every ~16 s). Report to the box owner with the modules: `comment` xt_comment, `statistic` xt_statistic, `multiport` xt_multiport, `limit` xt_limit, `NFLOG` xt_NFLOG, `physdev` xt_physdev, `pkttype` xt_pkttype, `REJECT` ipt_REJECT and ip6t_REJECT, `hash:ip` ip_set_hash_ip, `hash:net` ip_set_hash_net, `hash:ip,port` ip_set_hash_ipport, `hash:ip,port,ip` ip_set_hash_ipportip, `hash:ip,port,net` ip_set_hash_ipportnet. `nfprobe rc=2`: no verdict (docker did not answer, or no nf_tables iptables in the image); stop | +| H10 | `timeout 300 bash $R/bench/wildct/nfprobe.sh "$(cat $W/runs/IMAGE)"; echo "nfprobe rc=$?"` (a throwaway container from the release node image: `--network none`, NET_ADMIN and NET_RAW only, CPUs 20-23; adds one rule per iptables extension in its list, in its own network namespace, and one ipset per set type in its list) | `NFPROBE clean`, `nfprobe rc=0` | `NFPROBE missing: ...`: k3s's iptables rules fail (`smoke3`, 2026-10-29: the network policy controller stopped on `comment` and k3s restarted every ~16 s). Report to the box owner with the modules: `comment` xt_comment, `statistic` xt_statistic, `multiport` xt_multiport, `limit` xt_limit and nft_limit, `NFLOG` xt_NFLOG and nft_log (iptables-nft writes these two as native nftables expressions: with the `xt_` module loaded and the `nft_` one not, the line reads `RULE_APPEND failed (No such file or directory)`, 2026-10-29), `physdev` xt_physdev, `pkttype` xt_pkttype, `REJECT` ipt_REJECT and ip6t_REJECT, `hash:ip` ip_set_hash_ip, `hash:net` ip_set_hash_net, `hash:ip,port` ip_set_hash_ipport, `hash:ip,port,ip` ip_set_hash_ipportip, `hash:ip,port,net` ip_set_hash_ipportnet. `nfprobe rc=2`: no verdict (docker did not answer, or no nf_tables iptables in the image); stop | Record the whole output in `$W/runs/resume-health.txt` (move an earlier one aside first). Also copy it back with the records. `runs/IMAGE` is step 3c's; in a campaign that has not reached 3c, @@ -296,16 +303,19 @@ setsid nohup bash -c 'echo $$ > '"$W"'/runs/convert/pid; bash '"$R"'/bench/wildc docker did not answer, not that none is running. Stop only those containers. Then stop the pack. -## 5. Wiring smoke (`smoke4`) +## 5. Wiring smoke (`smoke4`: passed 2026-10-30) This is W1's smoke: an `up` with the opt-out and W3, the pod wait, the GPU scan, then `down`. It sends no inference request. It is discarded and disclosed, and is not a hold of any row. `smoke2` (2026-10-29) failed at `up`: neither module of step 1's H9 was loaded. `smoke3` (2026-10-29) printed every pass marker on a cluster whose k3s restarted every ~16 s (H10's `comment`); the pod wait now needs every node Ready and every container ready -(`bench/wildct/ready.sh`, amendment R3). The next smoke is `smoke4`. A `GPU-SCAN` line is a -verdict only on a non-empty pod list: `gpu_scan.py` exits 1 with no `GPU-SCAN` line on an empty -one (amendment R2). +(`bench/wildct/ready.sh`, amendment R3). A `GPU-SCAN` line is a verdict only on a non-empty pod +list: `gpu_scan.py` exits 1 with no `GPU-SCAN` line on an empty one (amendment R2). + +**`smoke4` passed on 2026-10-30 with 0.1.81 (amendment R5), so this step is done.** Run it again, +as `smoke5` in place of `smoke4` below, only when step 3c has installed a release other than +0.1.81 since; `launch` refuses a NAME whose `runs/` entry exists. **[w] launch** (the same function serves every hold below): diff --git a/bench/wildct/results/2026-10-29/diag/nftexpr.sh.txt b/bench/wildct/results/2026-10-29/diag/nftexpr.sh.txt new file mode 100644 index 00000000..0048bb3b --- /dev/null +++ b/bench/wildct/results/2026-10-29/diag/nftexpr.sh.txt @@ -0,0 +1,18 @@ +#!/bin/bash +# nftexpr.sh: which native nftables expressions this kernel serves, in a throwaway network +# namespace (unshare --net; its table goes with it), with the host's nft. Run as root. The +# expressions are the ones iptables-nft emits for `-m limit` (limit) and `-j NFLOG` (log group), +# against two it already serves (counter, meta mark). Loads nothing: kernel.modprobe is empty. +before=$(awk '{print $1}' /proc/modules | sort | sha256sum) +unshare --net bash -c ' +nft add table ip k3dgpuprobe && nft add chain ip k3dgpuprobe c || { echo "nftexpr: no table"; exit 2; } +r() { n=$1; shift; if o=$(nft add rule ip k3dgpuprobe c "$@" 2>&1); then echo "ok nft $n"; else echo "FAIL nft $n :: $(echo "$o" | head -1)"; fi; } +r counter counter accept +r meta-mark meta mark 0x4000 accept +r limit limit rate 10/minute burst 10 packets accept +r log-group log prefix probe group 100 +' +echo "unshare rc=$?" +after=$(awk '{print $1}' /proc/modules | sort | sha256sum) +[ "$before" = "$after" ] && echo "modules unchanged" || echo "MODULES CHANGED" +echo "modprobe=[$(cat /proc/sys/kernel/modprobe)]" diff --git a/bench/wildct/results/2026-10-29/host/modules-netfilter.txt b/bench/wildct/results/2026-10-29/host/modules-netfilter.txt new file mode 100644 index 00000000..4de4b38e --- /dev/null +++ b/bench/wildct/results/2026-10-29/host/modules-netfilter.txt @@ -0,0 +1,239 @@ +# wildct kernel modules for k3s, second load (wildct amendment R4). The box owner approved loading and +# persisting xt_comment xt_statistic xt_multiport xt_limit xt_NFLOG xt_physdev xt_pkttype ipt_REJECT +# ip6t_REJECT ip_set_hash_ip ip_set_hash_net ip_set_hash_ipport ip_set_hash_ipportip ip_set_hash_ipportnet +# on 2026-10-29, with kernel.modprobe left empty. `modprobe -a` of those 14 (2026-10-29T21:16:54Z, rc 0) +# also loaded three hard dependencies: nfnetlink_log (of xt_NFLOG), nf_reject_ipv4 (of ipt_REJECT) and +# nf_reject_ipv6 (of ip6t_REJECT); x_tables, nfnetlink and ip_set were already loaded. Read as root. +# Production checks (every container's state, health, restart count and start time; the HTTP status of +# eight published endpoints on the host and seven through its tailnet address; Prometheus's scrape +# targets) read identical before, at once after, 3 minutes after the load, and after H10 and nftexpr.sh. +# The firewall's rule counts and rule hashes (counters stripped) did not change. Then the 14 modules were +# appended to /etc/modules-load.d/k3dgpu-wildct.conf; every line of it resolves (modprobe -n) and is loaded. + +== before +## date 2026-10-29T17:16:40-04:00 +## modules (name refcount users) of: x_tables nfnetlink nfnetlink_log ip_set nf_reject_ipv4 nf_reject_ipv6 xt_comment xt_statistic xt_multiport xt_limit xt_NFLOG xt_physdev xt_pkttype ipt_REJECT ip6t_REJECT ip_set_hash_ip ip_set_hash_net ip_set_hash_ipport ip_set_hash_ipportip ip_set_hash_ipportnet vxlan br_netfilter bridge +x_tables 10 xt_set,xt_addrtype,xt_connmark,xt_mark,xt_MASQUERADE,xt_nat,xt_tcpudp,xt_recent,xt_conntrack,nft_compat, +nfnetlink 7 nf_conntrack_netlink,ip_set,nft_compat,nf_tables, +ip_set 1 xt_set, +vxlan 0 - +br_netfilter 0 - +bridge 1 br_netfilter, +lsmod lines: 205 +modprobe=[] +## sysctl net.bridge +net.bridge.bridge-nf-call-arptables = 1 +net.bridge.bridge-nf-call-ip6tables = 1 +net.bridge.bridge-nf-call-iptables = 1 +net.bridge.bridge-nf-filter-pppoe-tagged = 0 +net.bridge.bridge-nf-filter-vlan-tagged = 0 +net.bridge.bridge-nf-pass-vlan-input-dev = 0 +## firewall +iptables-save lines: 180 +iptables-save rules sha256 (counters stripped): e6d595562115c7dd +ip6tables-save lines: 53 +ip6tables-save rules sha256 (counters stripped): 15148e6b37881989 +nft ruleset lines: 286 +nft ruleset sha256 (counters stripped): 85443258b0a2a13d +## iptables -S FORWARD +-P FORWARD DROP +-A FORWARD -j DOCKER-USER +-A FORWARD -j DOCKER-FORWARD +-A FORWARD -j ts-forward +## modules-load.d +total 16 +-rw-r--r-- 1 root root 287 Oct 29 15:44 k3dgpu-wildct.conf +-rw-r--r-- 1 root root 34 Oct 13 2025 modules.conf +-rw-r--r-- 1 root root 16 Jul 25 17:06 nfs-client.conf +-rw-r--r-- 1 root root 7 Jun 1 18:32 uinput.conf + +== modprobe -a (as root) +2026-10-29T21:16:54Z +modprobe rc=0 +2026-10-29T21:16:54Z + +== at once after +## date 2026-10-29T17:16:58-04:00 +## modules (name refcount users) of: x_tables nfnetlink nfnetlink_log ip_set nf_reject_ipv4 nf_reject_ipv6 xt_comment xt_statistic xt_multiport xt_limit xt_NFLOG xt_physdev xt_pkttype ipt_REJECT ip6t_REJECT ip_set_hash_ip ip_set_hash_net ip_set_hash_ipport ip_set_hash_ipportip ip_set_hash_ipportnet vxlan br_netfilter bridge +x_tables 19 ip6t_REJECT,ipt_REJECT,xt_pkttype,xt_physdev,xt_NFLOG,xt_limit,xt_multiport,xt_statistic,xt_comment,xt_set,xt_addrtype,xt_connmark,xt_mark,xt_MASQUERADE,xt_nat,xt_tcpudp,xt_recent,xt_conntrack,nft_compat, +nfnetlink 8 nfnetlink_log,nf_conntrack_netlink,ip_set,nft_compat,nf_tables, +nfnetlink_log 0 - +ip_set 6 ip_set_hash_ipportnet,ip_set_hash_ipportip,ip_set_hash_ipport,ip_set_hash_net,ip_set_hash_ip,xt_set, +nf_reject_ipv4 1 ipt_REJECT, +nf_reject_ipv6 1 ip6t_REJECT, +xt_comment 0 - +xt_statistic 0 - +xt_multiport 0 - +xt_limit 0 - +xt_NFLOG 0 - +xt_physdev 0 - +xt_pkttype 0 - +ipt_REJECT 0 - +ip6t_REJECT 0 - +ip_set_hash_ip 0 - +ip_set_hash_net 0 - +ip_set_hash_ipport 0 - +ip_set_hash_ipportip 0 - +ip_set_hash_ipportnet 0 - +vxlan 0 - +br_netfilter 0 - +bridge 1 br_netfilter, +lsmod lines: 222 +modprobe=[] +## sysctl net.bridge +net.bridge.bridge-nf-call-arptables = 1 +net.bridge.bridge-nf-call-ip6tables = 1 +net.bridge.bridge-nf-call-iptables = 1 +net.bridge.bridge-nf-filter-pppoe-tagged = 0 +net.bridge.bridge-nf-filter-vlan-tagged = 0 +net.bridge.bridge-nf-pass-vlan-input-dev = 0 +## firewall +iptables-save lines: 180 +iptables-save rules sha256 (counters stripped): e6d595562115c7dd +ip6tables-save lines: 53 +ip6tables-save rules sha256 (counters stripped): 15148e6b37881989 +nft ruleset lines: 286 +nft ruleset sha256 (counters stripped): 85443258b0a2a13d +## iptables -S FORWARD +-P FORWARD DROP +-A FORWARD -j DOCKER-USER +-A FORWARD -j DOCKER-FORWARD +-A FORWARD -j ts-forward +## modules-load.d +total 16 +-rw-r--r-- 1 root root 287 Oct 29 15:44 k3dgpu-wildct.conf +-rw-r--r-- 1 root root 34 Oct 13 2025 modules.conf +-rw-r--r-- 1 root root 16 Jul 25 17:06 nfs-client.conf +-rw-r--r-- 1 root root 7 Jun 1 18:32 uinput.conf + +== 3 minutes after +## date 2026-10-29T17:20:21-04:00 +## modules (name refcount users) of: x_tables nfnetlink nfnetlink_log ip_set nf_reject_ipv4 nf_reject_ipv6 xt_comment xt_statistic xt_multiport xt_limit xt_NFLOG xt_physdev xt_pkttype ipt_REJECT ip6t_REJECT ip_set_hash_ip ip_set_hash_net ip_set_hash_ipport ip_set_hash_ipportip ip_set_hash_ipportnet vxlan br_netfilter bridge +x_tables 19 ip6t_REJECT,ipt_REJECT,xt_pkttype,xt_physdev,xt_NFLOG,xt_limit,xt_multiport,xt_statistic,xt_comment,xt_set,xt_addrtype,xt_connmark,xt_mark,xt_MASQUERADE,xt_nat,xt_tcpudp,xt_recent,xt_conntrack,nft_compat, +nfnetlink 8 nfnetlink_log,nf_conntrack_netlink,ip_set,nft_compat,nf_tables, +nfnetlink_log 0 - +ip_set 6 ip_set_hash_ipportnet,ip_set_hash_ipportip,ip_set_hash_ipport,ip_set_hash_net,ip_set_hash_ip,xt_set, +nf_reject_ipv4 1 ipt_REJECT, +nf_reject_ipv6 1 ip6t_REJECT, +xt_comment 0 - +xt_statistic 0 - +xt_multiport 0 - +xt_limit 0 - +xt_NFLOG 0 - +xt_physdev 0 - +xt_pkttype 0 - +ipt_REJECT 0 - +ip6t_REJECT 0 - +ip_set_hash_ip 0 - +ip_set_hash_net 0 - +ip_set_hash_ipport 0 - +ip_set_hash_ipportip 0 - +ip_set_hash_ipportnet 0 - +vxlan 0 - +br_netfilter 0 - +bridge 1 br_netfilter, +lsmod lines: 222 +modprobe=[] +## sysctl net.bridge +net.bridge.bridge-nf-call-arptables = 1 +net.bridge.bridge-nf-call-ip6tables = 1 +net.bridge.bridge-nf-call-iptables = 1 +net.bridge.bridge-nf-filter-pppoe-tagged = 0 +net.bridge.bridge-nf-filter-vlan-tagged = 0 +net.bridge.bridge-nf-pass-vlan-input-dev = 0 +## firewall +iptables-save lines: 180 +iptables-save rules sha256 (counters stripped): e6d595562115c7dd +ip6tables-save lines: 53 +ip6tables-save rules sha256 (counters stripped): 15148e6b37881989 +nft ruleset lines: 286 +nft ruleset sha256 (counters stripped): 85443258b0a2a13d +## iptables -S FORWARD +-P FORWARD DROP +-A FORWARD -j DOCKER-USER +-A FORWARD -j DOCKER-FORWARD +-A FORWARD -j ts-forward +## modules-load.d +total 16 +-rw-r--r-- 1 root root 287 Oct 29 15:44 k3dgpu-wildct.conf +-rw-r--r-- 1 root root 34 Oct 13 2025 modules.conf +-rw-r--r-- 1 root root 16 Jul 25 17:06 nfs-client.conf +-rw-r--r-- 1 root root 7 Jun 1 18:32 uinput.conf + +== after the modules-load.d append, step 1 (H10) and nftexpr.sh +## date 2026-10-29T17:23:20-04:00 +## modules (name refcount users) of: x_tables nfnetlink nfnetlink_log ip_set nf_reject_ipv4 nf_reject_ipv6 xt_comment xt_statistic xt_multiport xt_limit xt_NFLOG xt_physdev xt_pkttype ipt_REJECT ip6t_REJECT ip_set_hash_ip ip_set_hash_net ip_set_hash_ipport ip_set_hash_ipportip ip_set_hash_ipportnet vxlan br_netfilter bridge +x_tables 19 ip6t_REJECT,ipt_REJECT,xt_pkttype,xt_physdev,xt_NFLOG,xt_limit,xt_multiport,xt_statistic,xt_comment,xt_set,xt_addrtype,xt_connmark,xt_mark,xt_MASQUERADE,xt_nat,xt_tcpudp,xt_recent,xt_conntrack,nft_compat, +nfnetlink 8 nfnetlink_log,nf_conntrack_netlink,ip_set,nft_compat,nf_tables, +nfnetlink_log 0 - +ip_set 6 ip_set_hash_ipportnet,ip_set_hash_ipportip,ip_set_hash_ipport,ip_set_hash_net,ip_set_hash_ip,xt_set, +nf_reject_ipv4 1 ipt_REJECT, +nf_reject_ipv6 1 ip6t_REJECT, +xt_comment 0 - +xt_statistic 0 - +xt_multiport 0 - +xt_limit 0 - +xt_NFLOG 0 - +xt_physdev 0 - +xt_pkttype 0 - +ipt_REJECT 0 - +ip6t_REJECT 0 - +ip_set_hash_ip 0 - +ip_set_hash_net 0 - +ip_set_hash_ipport 0 - +ip_set_hash_ipportip 0 - +ip_set_hash_ipportnet 0 - +vxlan 0 - +br_netfilter 0 - +bridge 1 br_netfilter, +lsmod lines: 222 +modprobe=[] +## sysctl net.bridge +net.bridge.bridge-nf-call-arptables = 1 +net.bridge.bridge-nf-call-ip6tables = 1 +net.bridge.bridge-nf-call-iptables = 1 +net.bridge.bridge-nf-filter-pppoe-tagged = 0 +net.bridge.bridge-nf-filter-vlan-tagged = 0 +net.bridge.bridge-nf-pass-vlan-input-dev = 0 +## firewall +iptables-save lines: 180 +iptables-save rules sha256 (counters stripped): e6d595562115c7dd +ip6tables-save lines: 53 +ip6tables-save rules sha256 (counters stripped): 15148e6b37881989 +nft ruleset lines: 286 +nft ruleset sha256 (counters stripped): 85443258b0a2a13d +## iptables -S FORWARD +-P FORWARD DROP +-A FORWARD -j DOCKER-USER +-A FORWARD -j DOCKER-FORWARD +-A FORWARD -j ts-forward +## modules-load.d +total 16 +-rw-r--r-- 1 root root 841 Oct 29 17:20 k3dgpu-wildct.conf +-rw-r--r-- 1 root root 34 Oct 13 2025 modules.conf +-rw-r--r-- 1 root root 16 Jul 25 17:06 nfs-client.conf +-rw-r--r-- 1 root root 7 Jun 1 18:32 uinput.conf +## /etc/modules-load.d/k3dgpu-wildct.conf (root:root 644) +# k3d-gpu (github 88plug/k3d-gpu) wildct campaign, 2026-10-29: k3s's flannel needs vxlan and +# k3s needs br_netfilter (net.bridge.bridge-nf-call-iptables). Loaded at boot by +# systemd-modules-load. Approved by the box owner on 2026-10-29. Remove this file to stop it. +vxlan +br_netfilter +# k3d-gpu (github 88plug/k3d-gpu) wildct campaign, 2026-10-29, second approval: the iptables +# extensions and ipset set types k3s's rules use, which bench/wildct/nfprobe.sh checks +# (smoke3: k3s's network policy controller stopped on the comment extension). kernel.modprobe +# stays empty. Approved by the box owner on 2026-10-29. Remove these lines to stop it. +xt_comment +xt_statistic +xt_multiport +xt_limit +xt_NFLOG +xt_physdev +xt_pkttype +ipt_REJECT +ip6t_REJECT +ip_set_hash_ip +ip_set_hash_net +ip_set_hash_ipport +ip_set_hash_ipportip +ip_set_hash_ipportnet diff --git a/bench/wildct/results/2026-10-29/runs/nftexpr.txt b/bench/wildct/results/2026-10-29/runs/nftexpr.txt new file mode 100644 index 00000000..d881d392 --- /dev/null +++ b/bench/wildct/results/2026-10-29/runs/nftexpr.txt @@ -0,0 +1,12 @@ +# diag/nftexpr.sh.txt on wildct as root at about 2026-10-29T21:23Z, after step 1's H10 read `NFPROBE missing: NFLOG limit` +# (runs/resume-health-r4.txt): a diagnostic. In a throwaway network namespace (unshare --net) the host's nft adds one +# rule per native nftables expression. iptables-nft writes `-m limit` as nftables' limit expression and `-j NFLOG` as its +# log expression; nf_tables answers ENOENT for an expression type it does not have (nft_limit, alias nft-expr-limit, and +# nft_log, alias nft-expr-log, both on disk for 6.6.156-2-MANJARO, not loaded). /proc/modules identical before and after. +ok nft counter +ok nft meta-mark +FAIL nft limit :: Error: Could not process rule: No such file or directory +FAIL nft log-group :: Error: Could not process rule: No such file or directory +unshare rc=0 +modules unchanged +modprobe=[] diff --git a/bench/wildct/results/2026-10-29/runs/resume-health-r4.txt b/bench/wildct/results/2026-10-29/runs/resume-health-r4.txt new file mode 100644 index 00000000..53f03331 --- /dev/null +++ b/bench/wildct/results/2026-10-29/runs/resume-health-r4.txt @@ -0,0 +1,75 @@ +== resume step 1 2026-10-29T21:21:50Z repo=e5748cd112f2fdc2cf2e02962461f69f7938c17d +-- H1 +active + +active +H1 rc=0 +-- H2 +running +H2 rc=0 +-- H3 +29.7.2 live-restore=false cgroup=systemd/2 +H3 rc=0 +-- H4 +probe rc=0 +-- H5 +H5 done +-- H6 +/dev/nvme0n1p2 3667G 3273G 208G 95% / +MemAvailable: 123766348 kB +1.16 0.68 0.55 2/3113 23701 +-- H7 + +H7 rc=0/0 +-- H8 +lock-free +H8 done +-- H9 +br_netfilter vxlan +modprobe=[] +-- H10 +ok ipset hash:ip +ok ipset hash:net +ok ipset hash:ip,port +ok ipset hash:ip,port,ip +ok ipset hash:ip,port,net +ok iptables-nft filter comment +ok iptables-nft filter statistic +ok iptables-nft filter recent +ok iptables-nft filter conntrack +ok iptables-nft filter addrtype +ok iptables-nft filter mark +ok iptables-nft filter MARK +ok iptables-nft filter REJECT +ok iptables-nft filter tcp +ok iptables-nft filter udp +ok iptables-nft filter multiport +FAIL iptables-nft filter limit :: iptables v1.8.11 (nf_tables): RULE_APPEND failed (No such file or directory): rule in chain K3DGPU_PROBE +FAIL iptables-nft filter NFLOG :: iptables v1.8.11 (nf_tables): RULE_APPEND failed (No such file or directory): rule in chain K3DGPU_PROBE +ok iptables-nft filter physdev +ok iptables-nft filter pkttype +ok iptables-nft nat MASQUERADE +ok iptables-nft nat comment +ok ip6tables-nft filter comment +ok ip6tables-nft filter statistic +ok ip6tables-nft filter recent +ok ip6tables-nft filter conntrack +ok ip6tables-nft filter addrtype +ok ip6tables-nft filter mark +ok ip6tables-nft filter MARK +ok ip6tables-nft filter REJECT +ok ip6tables-nft filter tcp +ok ip6tables-nft filter udp +ok ip6tables-nft filter multiport +FAIL ip6tables-nft filter limit :: ip6tables v1.8.11 (nf_tables): RULE_APPEND failed (No such file or directory): rule in chain K3DGPU_PROBE +FAIL ip6tables-nft filter NFLOG :: ip6tables v1.8.11 (nf_tables): RULE_APPEND failed (No such file or directory): rule in chain K3DGPU_PROBE +ok ip6tables-nft filter physdev +ok ip6tables-nft filter pkttype +ok ip6tables-nft nat MASQUERADE +ok ip6tables-nft nat comment +ok iptables-nft nat DNAT +ok iptables-nft nat SNAT +ok ip6tables-nft nat DNAT +iptables: iptables v1.8.11 (nf_tables) +NFPROBE missing: NFLOG limit +nfprobe rc=1 diff --git a/bench/wildct/results/2026-10-30/diag/modstate.sh.txt b/bench/wildct/results/2026-10-30/diag/modstate.sh.txt new file mode 100644 index 00000000..f1aef3f4 --- /dev/null +++ b/bench/wildct/results/2026-10-30/diag/modstate.sh.txt @@ -0,0 +1,11 @@ +# modstate.sh: read-only, as root. The third load's modules, their dependencies, and the firewall's line counts. +echo "date $(date -u +%FT%TZ) kernel $(uname -r) modprobe=[$(cat /proc/sys/kernel/modprobe)]" +lsmod | awk '$1 == "nft_limit" || $1 == "nft_log" || $1 == "nf_tables" {print "lsmod", $1, $3, $4}' +echo "depends nft_limit: [$(modinfo -F depends nft_limit)]" +echo "depends nft_log: [$(modinfo -F depends nft_log)]" +echo "iptables-save lines: $(iptables-save 2>/dev/null | wc -l)" +echo "nft ruleset lines: $(nft list ruleset 2>/dev/null | wc -l)" +echo "netfilter module count: $(lsmod | grep -cE '^(nf|nft|xt|ip_set|ipt|ip6t)_')" +echo "modules-load.d lines: $(grep -vc '^#' /etc/modules-load.d/k3dgpu-wildct.conf)" +for m in $(grep -v '^#' /etc/modules-load.d/k3dgpu-wildct.conf); do awk -v m="$m" '$1 == m {f=1} END {if (!f) print "NOT LOADED", m}' /proc/modules; done +echo "every listed module loaded check done" diff --git a/bench/wildct/results/2026-10-30/diag/smoke4-live.sh.txt b/bench/wildct/results/2026-10-30/diag/smoke4-live.sh.txt new file mode 100644 index 00000000..d93e3d2c --- /dev/null +++ b/bench/wildct/results/2026-10-30/diag/smoke4-live.sh.txt @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# smoke4-live.sh: read-only live view of the smoke4 cluster while it runs (GPU isolation and node +# restarts). Every 10 s, at most 1200 s: the node container's state and docker restart count; +# once the node reads Ready, one full read of DaemonSets, pods, node capacity/allocatable and pod +# /dev mounts. Ends when the node container is gone after having existed. Writes nothing on the +# cluster. Not part of the hold (no WILDCT_HOLD in its environment). +W=~/k3dgpu-bench-wildct; export PATH=$W/bin:$PATH +export KUBECONFIG=$W/runs/smoke4/out/kubeconfig +N=k3d-k3dgpu-w-smoke-server-0 +seen=0 full=0 t0=$SECONDS +while [ $(( SECONDS - t0 )) -lt 1200 ]; do + st=$(timeout -k 5 30 docker inspect -f '{{.State.Status}} restarts={{.RestartCount}} started={{.State.StartedAt}}' "$N" 2>/dev/null) + if [ -n "$st" ]; then + seen=1 + echo "$(date -u +%FT%TZ) node-container $st" + if [ "$full" = 0 ] && [ -s "$KUBECONFIG" ] \ + && timeout -k 5 30 kubectl get nodes --no-headers 2>/dev/null | awk '$2 == "Ready" {ok=1} END {exit !ok}' \ + && bash $W/repo/bench/wildct/ready.sh; then + full=1 + echo "== live read $(date -u +%FT%TZ) (node Ready, every pod ready)" + echo "-- kubectl get ds -A"; timeout -k 5 30 kubectl get ds -A + echo "-- kubectl get pods -A -o wide"; timeout -k 5 30 kubectl get pods -A -o wide + echo "-- node capacity / allocatable" + timeout -k 5 30 kubectl get node -o jsonpath='{range .items[*]}{.metadata.name} capacity={.status.capacity} allocatable={.status.allocatable} taints={.spec.taints}{"\n"}{end}' + echo "-- pods with a hostPath under /dev, or a GPU resource" + timeout -k 5 30 kubectl get pods -A -o json | python3 -c ' +import json, sys +d = json.load(sys.stdin); n = 0 +for p in d["items"]: + name = p["metadata"]["namespace"] + "/" + p["metadata"]["name"] + for v in p["spec"].get("volumes", []): + hp = (v.get("hostPath") or {}).get("path", "") + if hp == "/dev" or hp.startswith("/dev/"): + print(name, "hostPath", hp); n += 1 + for c in p["spec"].get("containers", []) + p["spec"].get("initContainers", []): + r = {**(c.get("resources", {}).get("limits") or {}), **(c.get("resources", {}).get("requests") or {})} + for k in r: + if "nvidia" in k or "gpu" in k or "intel" in k: + print(name, "asks", k); n += 1 +print("pods", len(d["items"]), "dev-or-gpu findings", n)' + echo "-- node container devices" + timeout -k 5 30 docker inspect -f 'privileged={{.HostConfig.Privileged}} devices={{.HostConfig.Devices}} runtime={{.HostConfig.Runtime}} gpus={{.HostConfig.DeviceRequests}} binds={{.HostConfig.Binds}}' "$N" + echo "== end live read" + fi + elif [ "$seen" = 1 ]; then + echo "$(date -u +%FT%TZ) node container gone"; break + fi + sleep 10 +done +echo "$(date -u +%FT%TZ) live view done (seen=$seen full=$full)" diff --git a/bench/wildct/results/2026-10-30/host/modules-nft.txt b/bench/wildct/results/2026-10-30/host/modules-nft.txt new file mode 100644 index 00000000..b0e44339 --- /dev/null +++ b/bench/wildct/results/2026-10-30/host/modules-nft.txt @@ -0,0 +1,201 @@ +# wildct kernel modules for k3s, third load (wildct amendment R5). After amendment R4, H10 read +# "NFPROBE missing: NFLOG limit": iptables-nft writes -m limit and -j NFLOG as nftables' native limit +# and log expressions, which need nft_limit and nft_log. On 2026-10-29 (about 17:05 PDT) the box owner +# approved the netfilter module class (nf_/nft_/xt_/ipt_/ip6t_/ip_set*) for this campaign, each load +# with the same snapshot, check and rollback procedure. kernel.modprobe stays empty. +# +# Load (as root, through sudo): modprobe -a nft_limit nft_log -> "LOADED 2026-10-30T05:02:48Z" (rc 0). +# Snapshot (as andrew, read-only), the same commands each time: +# docker ps --format '{{.Names}}|{{.Status}}' | sed -E 's/Up [^(|]*/Up/' | sort +# for p in ; do curl -s -o /dev/null -m 5 -w '%{http_code}' http://127.0.0.1:$p/; done +# docker inspect -f '{{.Name}} {{.State.StartedAt}} {{.RestartCount}}' $(docker ps -q) | sort +# then, as root: iptables-save | wc -l; nft list ruleset | wc -l; +# lsmod | grep -cE '^(nf|nft|xt|ip_set|ipt|ip6t)_' (the netfilter module count) +# The container, port and start-time lines and both rule counts were identical before, at once after +# and again 2 min 18 s after the load; only the netfilter module count moved, 33 -> 35 (nft_limit and +# nft_log). This snapshot reads neither Prometheus's targets nor the tailnet address, which amendments +# R3 and R4 also read. +# Persist (as root): the three lines below appended to /etc/modules-load.d/k3dgpu-wildct.conf. +# Then bench/wildct/nfprobe.sh with the 0.1.79 node image read "NFPROBE clean" (its rc was not +# captured); step 1's H10 with the 0.1.81 node image read "NFPROBE clean", rc 0 +# (../runs/resume-health-r5.txt). + +== before (file written 2026-10-30T05:02:42Z) +bmd-telemetry-jaeger-1|Up +bmd-telemetry-prometheus-1|Up +feed-my-algorithm-app-1|Up +feed-my-algorithm-controller-1|Up(healthy) +feed-my-algorithm-db-1|Up(healthy) +feed-my-algorithm-redroid-1|Up +feed-my-algorithm-worker-1|Up +know-my-neighbor-db-1|Up(healthy) +proxmox-dc-manager|Up(healthy) +searxng-core|Up +searxng-valkey|Up(healthy) +state-of-my-town-app|Up +state-of-my-town-archivebox|Up(healthy) +state-of-my-town-postgres|Up(healthy) +tos-rate-calculator|Up(healthy) +webapp-adminer-1|Up +webapp-db-1|Up(healthy) +webapp-web-1|Up +port 3000 200 +port 3080 200 +port 3100 200 +port 4317 000 +port 5433 000 +port 5434 000 +port 5544 000 +port 5565 000 +port 8010 404 +port 8443 301 +port 8888 404 +port 8890 200 +port 9090 302 +port 13133 404 +port 16686 200 +/bmd-telemetry-jaeger-1 2026-10-29T09:11:27.91961244Z 0 +/bmd-telemetry-prometheus-1 2026-10-29T09:11:27.949546379Z 0 +/feed-my-algorithm-app-1 2026-10-29T09:11:27.936156879Z 0 +/feed-my-algorithm-controller-1 2026-10-29T09:11:27.939661363Z 0 +/feed-my-algorithm-db-1 2026-10-29T09:11:27.932636735Z 0 +/feed-my-algorithm-redroid-1 2026-10-29T09:11:27.907167381Z 0 +/feed-my-algorithm-worker-1 2026-10-29T09:11:27.938381448Z 0 +/know-my-neighbor-db-1 2026-10-29T09:11:27.946670984Z 0 +/proxmox-dc-manager 2026-10-29T09:11:27.95058685Z 0 +/searxng-core 2026-10-29T09:11:27.943106017Z 0 +/searxng-valkey 2026-10-29T09:11:27.920115646Z 0 +/state-of-my-town-app 2026-10-29T09:11:27.933239949Z 0 +/state-of-my-town-archivebox 2026-10-29T09:11:27.915974301Z 0 +/state-of-my-town-postgres 2026-10-29T09:11:27.948938903Z 0 +/tos-rate-calculator 2026-10-29T09:11:27.934425305Z 0 +/webapp-adminer-1 2026-10-29T09:11:27.945114439Z 0 +/webapp-db-1 2026-10-29T09:11:27.950054666Z 0 +/webapp-web-1 2026-10-29T09:11:27.947765425Z 0 +## as root: iptables-save lines, nft ruleset lines, netfilter module count +180 +286 +33 + +== at once after (file written 2026-10-30T05:02:49Z) +bmd-telemetry-jaeger-1|Up +bmd-telemetry-prometheus-1|Up +feed-my-algorithm-app-1|Up +feed-my-algorithm-controller-1|Up(healthy) +feed-my-algorithm-db-1|Up(healthy) +feed-my-algorithm-redroid-1|Up +feed-my-algorithm-worker-1|Up +know-my-neighbor-db-1|Up(healthy) +proxmox-dc-manager|Up(healthy) +searxng-core|Up +searxng-valkey|Up(healthy) +state-of-my-town-app|Up +state-of-my-town-archivebox|Up(healthy) +state-of-my-town-postgres|Up(healthy) +tos-rate-calculator|Up(healthy) +webapp-adminer-1|Up +webapp-db-1|Up(healthy) +webapp-web-1|Up +port 3000 200 +port 3080 200 +port 3100 200 +port 4317 000 +port 5433 000 +port 5434 000 +port 5544 000 +port 5565 000 +port 8010 404 +port 8443 301 +port 8888 404 +port 8890 200 +port 9090 302 +port 13133 404 +port 16686 200 +/bmd-telemetry-jaeger-1 2026-10-29T09:11:27.91961244Z 0 +/bmd-telemetry-prometheus-1 2026-10-29T09:11:27.949546379Z 0 +/feed-my-algorithm-app-1 2026-10-29T09:11:27.936156879Z 0 +/feed-my-algorithm-controller-1 2026-10-29T09:11:27.939661363Z 0 +/feed-my-algorithm-db-1 2026-10-29T09:11:27.932636735Z 0 +/feed-my-algorithm-redroid-1 2026-10-29T09:11:27.907167381Z 0 +/feed-my-algorithm-worker-1 2026-10-29T09:11:27.938381448Z 0 +/know-my-neighbor-db-1 2026-10-29T09:11:27.946670984Z 0 +/proxmox-dc-manager 2026-10-29T09:11:27.95058685Z 0 +/searxng-core 2026-10-29T09:11:27.943106017Z 0 +/searxng-valkey 2026-10-29T09:11:27.920115646Z 0 +/state-of-my-town-app 2026-10-29T09:11:27.933239949Z 0 +/state-of-my-town-archivebox 2026-10-29T09:11:27.915974301Z 0 +/state-of-my-town-postgres 2026-10-29T09:11:27.948938903Z 0 +/tos-rate-calculator 2026-10-29T09:11:27.934425305Z 0 +/webapp-adminer-1 2026-10-29T09:11:27.945114439Z 0 +/webapp-db-1 2026-10-29T09:11:27.950054666Z 0 +/webapp-web-1 2026-10-29T09:11:27.947765425Z 0 +## as root: iptables-save lines, nft ruleset lines, netfilter module count +180 +286 +35 + +== 2 min 18 s after (file written 2026-10-30T05:05:06Z) +bmd-telemetry-jaeger-1|Up +bmd-telemetry-prometheus-1|Up +feed-my-algorithm-app-1|Up +feed-my-algorithm-controller-1|Up(healthy) +feed-my-algorithm-db-1|Up(healthy) +feed-my-algorithm-redroid-1|Up +feed-my-algorithm-worker-1|Up +know-my-neighbor-db-1|Up(healthy) +proxmox-dc-manager|Up(healthy) +searxng-core|Up +searxng-valkey|Up(healthy) +state-of-my-town-app|Up +state-of-my-town-archivebox|Up(healthy) +state-of-my-town-postgres|Up(healthy) +tos-rate-calculator|Up(healthy) +webapp-adminer-1|Up +webapp-db-1|Up(healthy) +webapp-web-1|Up +port 3000 200 +port 3080 200 +port 3100 200 +port 4317 000 +port 5433 000 +port 5434 000 +port 5544 000 +port 5565 000 +port 8010 404 +port 8443 301 +port 8888 404 +port 8890 200 +port 9090 302 +port 13133 404 +port 16686 200 +/bmd-telemetry-jaeger-1 2026-10-29T09:11:27.91961244Z 0 +/bmd-telemetry-prometheus-1 2026-10-29T09:11:27.949546379Z 0 +/feed-my-algorithm-app-1 2026-10-29T09:11:27.936156879Z 0 +/feed-my-algorithm-controller-1 2026-10-29T09:11:27.939661363Z 0 +/feed-my-algorithm-db-1 2026-10-29T09:11:27.932636735Z 0 +/feed-my-algorithm-redroid-1 2026-10-29T09:11:27.907167381Z 0 +/feed-my-algorithm-worker-1 2026-10-29T09:11:27.938381448Z 0 +/know-my-neighbor-db-1 2026-10-29T09:11:27.946670984Z 0 +/proxmox-dc-manager 2026-10-29T09:11:27.95058685Z 0 +/searxng-core 2026-10-29T09:11:27.943106017Z 0 +/searxng-valkey 2026-10-29T09:11:27.920115646Z 0 +/state-of-my-town-app 2026-10-29T09:11:27.933239949Z 0 +/state-of-my-town-archivebox 2026-10-29T09:11:27.915974301Z 0 +/state-of-my-town-postgres 2026-10-29T09:11:27.948938903Z 0 +/tos-rate-calculator 2026-10-29T09:11:27.934425305Z 0 +/webapp-adminer-1 2026-10-29T09:11:27.945114439Z 0 +/webapp-db-1 2026-10-29T09:11:27.950054666Z 0 +/webapp-web-1 2026-10-29T09:11:27.947765425Z 0 +## as root: iptables-save lines, nft ruleset lines, netfilter module count +180 +286 +35 + +== /etc/modules-load.d/k3dgpu-wildct.conf, tail -4 after the append +ip_set_hash_ipportnet +# k3d-gpu wildct, 2026-10-29 (owner approved netfilter class): iptables-nft limit/NFLOG for k3s +nft_limit +nft_log + +== nfprobe.sh cryptoandcoffee/k3d-gpu:0.1.79@sha256:6e27ff9817bf9398edb20f479dceb5f08273a04a01971b37628a7b2791bdfcfe | grep -E 'FAIL|NFPROBE' +NFPROBE clean diff --git a/bench/wildct/results/2026-10-30/runs/IMAGE b/bench/wildct/results/2026-10-30/runs/IMAGE new file mode 100644 index 00000000..96b9cc68 --- /dev/null +++ b/bench/wildct/results/2026-10-30/runs/IMAGE @@ -0,0 +1 @@ +cryptoandcoffee/k3d-gpu:0.1.81@sha256:b68387c9c73ff57ab19facfa18196519a14df31576cdfb0d225f6f9401d2380e diff --git a/bench/wildct/results/2026-10-30/runs/install-0.1.81.log b/bench/wildct/results/2026-10-30/runs/install-0.1.81.log new file mode 100644 index 00000000..b446bb91 --- /dev/null +++ b/bench/wildct/results/2026-10-30/runs/install-0.1.81.log @@ -0,0 +1,9 @@ +ok k3d 06d8f25bc3a971c4eb29e0ff08429b180402db0f4dec838c9eac427e296800a0 +ok helm.tgz 0403a88ae20913d11b10a2467b50087721fea7581736da6b9d3992f5357fe54b +ok kubectl c04487523e7aa7d1493f7808174c899db93d7a9cf57531420cd76b9bd415f8f4 +ok k3d-gpu_0.1.81-1_all.deb beac5c26ef33bbe784c4cd42ff4c18b9588534df9189c284c93bfd57ed85fca3 +k3d version v5.9.0 +v4.3.1+gc0d08d0 +Client Version: v1.37.2 +k3d-gpu 0.1.81 +huggingface_hub 1.21.0 diff --git a/bench/wildct/results/2026-10-30/runs/resume-health-r5.txt b/bench/wildct/results/2026-10-30/runs/resume-health-r5.txt new file mode 100644 index 00000000..4e0b2a16 --- /dev/null +++ b/bench/wildct/results/2026-10-30/runs/resume-health-r5.txt @@ -0,0 +1,75 @@ +== resume step 1 2026-10-30T05:09:52Z repo=cf49f0e6555edc3836faa12f4a05bb4cbd07d17d +-- H1 +active + +active +H1 rc=0 +-- H2 +running +H2 rc=0 +-- H3 +29.7.2 live-restore=false cgroup=systemd/2 +H3 rc=0 +-- H4 +probe rc=0 +-- H5 +H5 done +-- H6 +/dev/nvme0n1p2 3667G 3273G 208G 95% / +MemAvailable: 123798776 kB +0.79 0.53 0.42 1/3107 41441 +-- H7 + +H7 rc=0/0 +-- H8 +lock-free +H8 done +-- H9 +br_netfilter vxlan +modprobe=[] +-- H10 +ok ipset hash:ip +ok ipset hash:net +ok ipset hash:ip,port +ok ipset hash:ip,port,ip +ok ipset hash:ip,port,net +ok iptables-nft filter comment +ok iptables-nft filter statistic +ok iptables-nft filter recent +ok iptables-nft filter conntrack +ok iptables-nft filter addrtype +ok iptables-nft filter mark +ok iptables-nft filter MARK +ok iptables-nft filter REJECT +ok iptables-nft filter tcp +ok iptables-nft filter udp +ok iptables-nft filter multiport +ok iptables-nft filter limit +ok iptables-nft filter NFLOG +ok iptables-nft filter physdev +ok iptables-nft filter pkttype +ok iptables-nft nat MASQUERADE +ok iptables-nft nat comment +ok ip6tables-nft filter comment +ok ip6tables-nft filter statistic +ok ip6tables-nft filter recent +ok ip6tables-nft filter conntrack +ok ip6tables-nft filter addrtype +ok ip6tables-nft filter mark +ok ip6tables-nft filter MARK +ok ip6tables-nft filter REJECT +ok ip6tables-nft filter tcp +ok ip6tables-nft filter udp +ok ip6tables-nft filter multiport +ok ip6tables-nft filter limit +ok ip6tables-nft filter NFLOG +ok ip6tables-nft filter physdev +ok ip6tables-nft filter pkttype +ok ip6tables-nft nat MASQUERADE +ok ip6tables-nft nat comment +ok iptables-nft nat DNAT +ok iptables-nft nat SNAT +ok ip6tables-nft nat DNAT +iptables: iptables v1.8.11 (nf_tables) +NFPROBE clean +nfprobe rc=0 diff --git a/bench/wildct/results/2026-10-30/runs/smoke4-live.txt b/bench/wildct/results/2026-10-30/runs/smoke4-live.txt new file mode 100644 index 00000000..b66d2f09 --- /dev/null +++ b/bench/wildct/results/2026-10-30/runs/smoke4-live.txt @@ -0,0 +1,6 @@ +2026-10-30T05:11:03Z node-container running restarts=0 started=2026-10-30T05:10:55.565798113Z +2026-10-30T05:11:13Z node-container running restarts=0 started=2026-10-30T05:10:55.565798113Z +2026-10-30T05:11:23Z node-container running restarts=0 started=2026-10-30T05:10:55.565798113Z +2026-10-30T05:11:33Z node-container running restarts=0 started=2026-10-30T05:10:55.565798113Z +2026-10-30T05:11:43Z node container gone +2026-10-30T05:11:43Z live view done (seen=1 full=0) diff --git a/bench/wildct/results/2026-10-30/runs/smoke4.out b/bench/wildct/results/2026-10-30/runs/smoke4.out new file mode 100644 index 00000000..c70512c8 --- /dev/null +++ b/bench/wildct/results/2026-10-30/runs/smoke4.out @@ -0,0 +1,4 @@ +== smoke4 waiting for the lock 2026-10-30T05:10:53Z +== smoke4 lock taken 2026-10-30T05:10:53Z +== smoke4 start: bash /home/andrew/k3dgpu-bench-wildct/repo/bench/wildct/row.sh smoke 2026-10-30T05:10:53Z +== smoke4 rc=0; clusters deleted: none; now: ; / 208 GB available 2026-10-30T05:11:44Z diff --git a/bench/wildct/results/2026-10-30/runs/smoke4/launcher.diff b/bench/wildct/results/2026-10-30/runs/smoke4/launcher.diff new file mode 100644 index 00000000..e69de29b diff --git a/bench/wildct/results/2026-10-30/runs/smoke4/out/down.log b/bench/wildct/results/2026-10-30/runs/smoke4/out/down.log new file mode 100644 index 00000000..bdeee14f --- /dev/null +++ b/bench/wildct/results/2026-10-30/runs/smoke4/out/down.log @@ -0,0 +1,7 @@ +==> Deleting k3d cluster 'k3dgpu-w-smoke'... +INFO[0000] Deleting cluster 'k3dgpu-w-smoke' +INFO[0000] Deleting cluster network 'k3d-k3dgpu-w-smoke' +INFO[0001] Deleting 1 attached volumes... +INFO[0001] Removing cluster details from default kubeconfig... +INFO[0001] Removing standalone kubeconfig file (if there is one)... +INFO[0001] Successfully deleted cluster k3dgpu-w-smoke! diff --git a/bench/wildct/results/2026-10-30/runs/smoke4/out/gpu-scan.txt b/bench/wildct/results/2026-10-30/runs/smoke4/out/gpu-scan.txt new file mode 100644 index 00000000..04f33f92 --- /dev/null +++ b/bench/wildct/results/2026-10-30/runs/smoke4/out/gpu-scan.txt @@ -0,0 +1,34 @@ +kube-system/coredns-577d995dff-76hth hostPath +kube-system/coredns-577d995dff-76hth hostPath +kube-system/coredns-577d995dff-76hth hostPath +kube-system/helm-install-gateway-api-crd-v8zd7 hostPath +kube-system/helm-install-gateway-api-crd-v8zd7 hostPath +kube-system/helm-install-gateway-api-crd-v8zd7 hostPath +kube-system/helm-install-gateway-api-crd-v8zd7 hostPath +kube-system/helm-install-gateway-api-crd-v8zd7 hostPath +kube-system/helm-install-gateway-api-crd-v8zd7 hostPath +kube-system/helm-install-gateway-api-crd-v8zd7 hostPath +kube-system/local-path-provisioner-6858d854cf-27b87 hostPath +kube-system/local-path-provisioner-6858d854cf-27b87 hostPath +kube-system/metrics-server-778c4649b4-v42kk hostPath +kube-system/metrics-server-778c4649b4-v42kk hostPath +kube-system/nri-resource-policy-balloons-cx8wm hostPath /var/lib/nri-resource-policy +kube-system/nri-resource-policy-balloons-cx8wm hostPath /sys +kube-system/nri-resource-policy-balloons-cx8wm hostPath /proc +kube-system/nri-resource-policy-balloons-cx8wm hostPath /var/run/nri-resource-policy +kube-system/nri-resource-policy-balloons-cx8wm hostPath /var/run/nri +kube-system/nri-resource-policy-balloons-cx8wm hostPath /var/lib/kubelet/pod-resources +kube-system/nri-resource-policy-balloons-cx8wm hostPath +node-feature-discovery/nfd-gc-8d4595cbc-gwzl9 hostPath +node-feature-discovery/nfd-master-74b44b767d-xr4mp hostPath +node-feature-discovery/nfd-master-74b44b767d-xr4mp hostPath +node-feature-discovery/nfd-worker-t8wcd hostPath /k3d-gpu/host/boot +node-feature-discovery/nfd-worker-t8wcd hostPath /proc/swaps +node-feature-discovery/nfd-worker-t8wcd hostPath /k3d-gpu/host/etc +node-feature-discovery/nfd-worker-t8wcd hostPath /sys +node-feature-discovery/nfd-worker-t8wcd hostPath /k3d-gpu/host/lib +node-feature-discovery/nfd-worker-t8wcd hostPath /k3d-gpu/host/lib +node-feature-discovery/nfd-worker-t8wcd hostPath /etc/kubernetes/node-feature-discovery/features.d/ +node-feature-discovery/nfd-worker-t8wcd hostPath +node-feature-discovery/nfd-worker-t8wcd hostPath +GPU-SCAN clean diff --git a/bench/wildct/results/2026-10-30/runs/smoke4/out/node-container.txt b/bench/wildct/results/2026-10-30/runs/smoke4/out/node-container.txt new file mode 100644 index 00000000..6ec648d7 --- /dev/null +++ b/bench/wildct/results/2026-10-30/runs/smoke4/out/node-container.txt @@ -0,0 +1 @@ +privileged=true devices=[] runtime=runc gpus=[] restarts=0 diff --git a/bench/wildct/results/2026-10-30/runs/smoke4/out/node.txt b/bench/wildct/results/2026-10-30/runs/smoke4/out/node.txt new file mode 100644 index 00000000..665dcc49 --- /dev/null +++ b/bench/wildct/results/2026-10-30/runs/smoke4/out/node.txt @@ -0,0 +1,2 @@ + +{"cpu":"24","ephemeral-storage":"3934360281088","hugepages-1Gi":"0","hugepages-2Mi":"0","memory":"131421688Ki","pods":"110"} diff --git a/bench/wildct/results/2026-10-30/runs/smoke4/out/pods.txt b/bench/wildct/results/2026-10-30/runs/smoke4/out/pods.txt new file mode 100644 index 00000000..7028f559 --- /dev/null +++ b/bench/wildct/results/2026-10-30/runs/smoke4/out/pods.txt @@ -0,0 +1,9 @@ +NAMESPACE NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +kube-system coredns-577d995dff-76hth 1/1 Running 0 34s 10.42.0.2 k3d-k3dgpu-w-smoke-server-0 +kube-system helm-install-gateway-api-crd-v8zd7 0/1 Completed 0 35s 10.42.0.9 k3d-k3dgpu-w-smoke-server-0 +kube-system local-path-provisioner-6858d854cf-27b87 1/1 Running 0 34s 10.42.0.8 k3d-k3dgpu-w-smoke-server-0 +kube-system metrics-server-778c4649b4-v42kk 1/1 Running 0 34s 10.42.0.4 k3d-k3dgpu-w-smoke-server-0 +kube-system nri-resource-policy-balloons-cx8wm 1/1 Running 0 34s 10.42.0.7 k3d-k3dgpu-w-smoke-server-0 +node-feature-discovery nfd-gc-8d4595cbc-gwzl9 1/1 Running 0 34s 10.42.0.3 k3d-k3dgpu-w-smoke-server-0 +node-feature-discovery nfd-master-74b44b767d-xr4mp 1/1 Running 0 34s 10.42.0.6 k3d-k3dgpu-w-smoke-server-0 +node-feature-discovery nfd-worker-t8wcd 1/1 Running 0 34s 10.42.0.5 k3d-k3dgpu-w-smoke-server-0 diff --git a/bench/wildct/results/2026-10-30/runs/smoke4/out/rc.txt b/bench/wildct/results/2026-10-30/runs/smoke4/out/rc.txt new file mode 100644 index 00000000..320342ad --- /dev/null +++ b/bench/wildct/results/2026-10-30/runs/smoke4/out/rc.txt @@ -0,0 +1,4 @@ +up rc=0 in 30 s +pods ready: yes after 16 s +GPU-SCAN clean +down rc=0 diff --git a/bench/wildct/results/2026-10-30/runs/smoke4/out/up.log b/bench/wildct/results/2026-10-30/runs/smoke4/out/up.log new file mode 100644 index 00000000..df12e0b9 --- /dev/null +++ b/bench/wildct/results/2026-10-30/runs/smoke4/out/up.log @@ -0,0 +1,34 @@ +==> opted out by K3D_GPU_VENDORS: nvidia intel (detected here: nvidia intel); not deployed, not advertised +==> Accelerators: hybrid +==> Experimental features (auto): dra image-volume +==> Creating k3d cluster 'k3dgpu-w-smoke' with image cryptoandcoffee/k3d-gpu:0.1.81@sha256:b68387c9c73ff57ab19facfa18196519a14df31576cdfb0d225f6f9401d2380e... +INFO[0000] Prep: Network +INFO[0000] Created network 'k3d-k3dgpu-w-smoke' +INFO[0000] Created image volume k3d-k3dgpu-w-smoke-images +INFO[0000] Starting new tools node... +INFO[0000] Starting node 'k3d-k3dgpu-w-smoke-tools' +INFO[0001] Creating node 'k3d-k3dgpu-w-smoke-server-0' +INFO[0001] Using the k3d-tools node to gather environment information +INFO[0001] HostIP: using network gateway 172.19.0.1 address +INFO[0001] Starting cluster 'k3dgpu-w-smoke' +INFO[0001] Starting servers... +INFO[0001] Starting node 'k3d-k3dgpu-w-smoke-server-0' +INFO[0004] All agents already running. +INFO[0004] All helpers already running. +INFO[0004] Injecting records for hostAliases (incl. host.k3d.internal) and for 1 network members into CoreDNS configmap... +INFO[0006] Cluster 'k3dgpu-w-smoke' created successfully! +INFO[0006] --kubeconfig-update-default=false --> sets --kubeconfig-switch-context=false +INFO[0006] You can now use it like this: +export KUBECONFIG=$(k3d kubeconfig write k3dgpu-w-smoke) +kubectl cluster-info +==> kubectl for this cluster: export KUBECONFIG=/home/andrew/k3dgpu-bench-wildct/runs/smoke4/out/kubeconfig +==> Waiting for nodes Ready... +node/k3d-k3dgpu-w-smoke-server-0 condition met +==> Waiting for cluster DNS (CoreDNS)... +node/k3d-k3dgpu-w-smoke-server-0 annotated +==> hybrid device plugin auto-deployed by k3s. +balloonspolicy.config.nri/default patched +==> Verifying accelerators are schedulable... +Waiting for daemon set "nri-resource-policy-balloons" rollout to finish: 0 of 1 updated pods are available... +daemon set "nri-resource-policy-balloons" successfully rolled out +==> Accelerators schedulable: hybrid=1. Try: k3d-gpu test diff --git a/bench/wildct/results/2026-10-30/runs/smoke4/pid b/bench/wildct/results/2026-10-30/runs/smoke4/pid new file mode 100644 index 00000000..5f342935 --- /dev/null +++ b/bench/wildct/results/2026-10-30/runs/smoke4/pid @@ -0,0 +1 @@ +48184 diff --git a/bench/wildct/results/2026-10-30/runs/smoke4/post.txt b/bench/wildct/results/2026-10-30/runs/smoke4/post.txt new file mode 100644 index 00000000..00154fe4 --- /dev/null +++ b/bench/wildct/results/2026-10-30/runs/smoke4/post.txt @@ -0,0 +1,17 @@ +2026-10-30T05:11:44Z + 01:11:44 up 20:00, 2 users, load average: 0.61, 0.53, 0.43 +%CPU PID USER COMMAND + 2.6 1157 root tailscaled + 2.0 6759 911 archivebox + 1.4 1 root systemd + 1.0 9906 911 python3 + 0.8 1184 root gdm + 0.7 57558 andrew localsearch-ext + 0.5 41181 andrew rustdesk + 0.3 2738 andrew localsearch-3 + 0.3 3313 root dockerd + 0.3 5486 root nxrunner.bin + total used free shared buff/cache available +Mem: 125 7 103 0 16 118 +/dev/nvme0n1p2 3667G 3273G 208G 95% / +bmd-telemetry-jaeger-1 bmd-telemetry-prometheus-1 feed-my-algorithm-app-1 feed-my-algorithm-controller-1 feed-my-algorithm-db-1 feed-my-algorithm-redroid-1 feed-my-algorithm-worker-1 know-my-neighbor-db-1 proxmox-dc-manager searxng-core searxng-valkey state-of-my-town-app state-of-my-town-archivebox state-of-my-town-postgres tos-rate-calculator webapp-adminer-1 webapp-db-1 webapp-web-1 diff --git a/bench/wildct/results/2026-10-30/runs/smoke4/pre.txt b/bench/wildct/results/2026-10-30/runs/smoke4/pre.txt new file mode 100644 index 00000000..7a3ade49 --- /dev/null +++ b/bench/wildct/results/2026-10-30/runs/smoke4/pre.txt @@ -0,0 +1,17 @@ +2026-10-30T05:10:53Z + 01:10:53 up 19:59, 2 users, load average: 0.46, 0.50, 0.41 +%CPU PID USER COMMAND + 2.6 1157 root tailscaled + 2.0 6759 911 archivebox + 1.4 1 root systemd + 1.0 9906 911 python3 + 0.8 1184 root gdm + 0.5 41181 andrew rustdesk + 0.3 2738 andrew localsearch-3 + 0.3 3313 root dockerd + 0.3 5486 root nxrunner.bin + 0.2 1180 root containerd + total used free shared buff/cache available +Mem: 125 7 103 0 16 118 +/dev/nvme0n1p2 3667G 3273G 208G 95% / +bmd-telemetry-jaeger-1 bmd-telemetry-prometheus-1 feed-my-algorithm-app-1 feed-my-algorithm-controller-1 feed-my-algorithm-db-1 feed-my-algorithm-redroid-1 feed-my-algorithm-worker-1 know-my-neighbor-db-1 proxmox-dc-manager searxng-core searxng-valkey state-of-my-town-app state-of-my-town-archivebox state-of-my-town-postgres tos-rate-calculator webapp-adminer-1 webapp-db-1 webapp-web-1 diff --git a/bench/wildct/results/2026-10-30/runs/smoke4/rc b/bench/wildct/results/2026-10-30/runs/smoke4/rc new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/bench/wildct/results/2026-10-30/runs/smoke4/rc @@ -0,0 +1 @@ +0 diff --git a/bench/wildct/results/2026-10-30/runs/smoke4/sample.jsonl b/bench/wildct/results/2026-10-30/runs/smoke4/sample.jsonl new file mode 100644 index 00000000..0ef774a3 --- /dev/null +++ b/bench/wildct/results/2026-10-30/runs/smoke4/sample.jsonl @@ -0,0 +1,10 @@ +{"ts": 1793337058.815, "seconds": 5.0, "load1": 0.5, "busy_p": 0.864, "busy_e_free": 0.298, "busy_e_load": 0.186, "busy_all": 1.348, "mine_cpu": 0.0, "foreign_cpu": 1.348, "other_containers_cpu": 0.043, "mem_available_gib": 117.74, "pkg_temp_c": 58.0, "root_avail_gb": 222.4} +{"ts": 1793337063.816, "seconds": 5.001, "load1": 0.46, "busy_p": 0.712, "busy_e_free": 0.258, "busy_e_load": 0.204, "busy_all": 1.174, "mine_cpu": 0.0, "foreign_cpu": 1.174, "other_containers_cpu": 0.061, "mem_available_gib": 117.68, "pkg_temp_c": 59.0, "root_avail_gb": 222.4} +{"ts": 1793337068.817, "seconds": 5.001, "load1": 0.42, "busy_p": 0.404, "busy_e_free": 0.214, "busy_e_load": 0.172, "busy_all": 0.79, "mine_cpu": 0.0, "foreign_cpu": 0.79, "other_containers_cpu": 0.046, "mem_available_gib": 117.6, "pkg_temp_c": 58.0, "root_avail_gb": 222.4} +{"ts": 1793337073.818, "seconds": 5.001, "load1": 0.39, "busy_p": 0.264, "busy_e_free": 0.128, "busy_e_load": 0.14, "busy_all": 0.532, "mine_cpu": 0.0, "foreign_cpu": 0.532, "other_containers_cpu": 0.055, "mem_available_gib": 117.6, "pkg_temp_c": 59.0, "root_avail_gb": 222.4} +{"ts": 1793337078.819, "seconds": 5.001, "load1": 0.36, "busy_p": 0.322, "busy_e_free": 0.146, "busy_e_load": 0.136, "busy_all": 0.604, "mine_cpu": 0.0, "foreign_cpu": 0.604, "other_containers_cpu": 0.035, "mem_available_gib": 117.56, "pkg_temp_c": 59.0, "root_avail_gb": 222.4} +{"ts": 1793337083.82, "seconds": 5.001, "load1": 0.57, "busy_p": 1.52, "busy_e_free": 0.39, "busy_e_load": 0.28, "busy_all": 2.19, "mine_cpu": 0.0, "foreign_cpu": 2.19, "other_containers_cpu": 0.104, "mem_available_gib": 117.26, "pkg_temp_c": 60.0, "root_avail_gb": 221.6} +{"ts": 1793337088.838, "seconds": 5.018, "load1": 0.68, "busy_p": 1.05, "busy_e_free": 0.413, "busy_e_load": 0.239, "busy_all": 1.702, "mine_cpu": 1.053, "foreign_cpu": 0.649, "other_containers_cpu": 0.051, "mem_available_gib": 117.23, "pkg_temp_c": 58.0, "root_avail_gb": 221.3} +{"ts": 1793337093.839, "seconds": 5.001, "load1": 0.63, "busy_p": 0.322, "busy_e_free": 0.148, "busy_e_load": 0.114, "busy_all": 0.584, "mine_cpu": 0.046, "foreign_cpu": 0.538, "other_containers_cpu": 0.074, "mem_available_gib": 117.25, "pkg_temp_c": 60.0, "root_avail_gb": 221.3} +{"ts": 1793337098.841, "seconds": 5.002, "load1": 0.66, "busy_p": 0.338, "busy_e_free": 0.16, "busy_e_load": 0.128, "busy_all": 0.626, "mine_cpu": 0.128, "foreign_cpu": 0.498, "other_containers_cpu": 0.043, "mem_available_gib": 117.23, "pkg_temp_c": 59.0, "root_avail_gb": 221.3} +{"ts": 1793337103.842, "seconds": 5.001, "load1": 0.61, "busy_p": 0.482, "busy_e_free": 0.158, "busy_e_load": 0.142, "busy_all": 0.782, "mine_cpu": 0.0, "foreign_cpu": 0.782, "other_containers_cpu": 0.055, "mem_available_gib": 118.02, "pkg_temp_c": 67.0, "root_avail_gb": 222.4} diff --git a/bench/wildct/results/2026-10-30/runs/smoke4/wait_s b/bench/wildct/results/2026-10-30/runs/smoke4/wait_s new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/bench/wildct/results/2026-10-30/runs/smoke4/wait_s @@ -0,0 +1 @@ +0 diff --git a/bench/wildct/row.sh b/bench/wildct/row.sh index 62415cf9..62d16aba 100755 --- a/bench/wildct/row.sh +++ b/bench/wildct/row.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# row.sh ROW [HOLD MODE [ARM...]]: one wildct row's command as the wildct pre-registration lists it ("Per +# row.sh ROW [ARGS...]: one wildct row's command as the wildct pre-registration lists it ("Per # row") with amendment R1, run as bench/wildct/hold.sh's CMD (hold.sh exports # K3D_GPU_RAPL_EXEC, rule W4, and WILDCT_HOLD, the hold's name) from the shipped repository # archive. The release's node image and the models are staged first (bench/wildct/RESUME.md). @@ -19,6 +19,11 @@ # h57w-convert H57d's bench/h57d/convert.sh (drafter GGUF, Od's graph.pbtxt) under the host # lock, not in a hold; at most 3600 s # h57wb H57wb's start reading (/ available, MemAvailable) and its decision; not a hold +# h59w HOLD [REPEATS] H59w: bench/h59/hold.sh wildct (inner lock; H59 amendment R1): holds 1-3 +# perf and 4 quality in their registered orders; a spare 5 or 6 names the failed +# hold it repeats +# h59w-prep bench/h59/prep.sh wildct: the downloads, then the drafter conversion under the +# host lock; not in a hold # Env: IMAGE the release node image as name:tag@sha256:digest (every row but h52w and h57wb) # OUT output directory (default ~/k3dgpu-bench-wildct/runs/${WILDCT_HOLD:-ROW}/out) # WILDCT_ROW_PRINT=1 print the environment (one NAME=value per line) and the command, run nothing @@ -95,6 +100,21 @@ case ${row} in ARGV=(timeout -k 30 3600 bash "${REPO}/bench/h57d/convert.sh") ;; h57wb) ARGV=(h57wb) ;; + h59w) + need_image + case ${2:-}:${3:-}:$# in + [1234]::2 | [56]:[1234]:3) ;; + *) echo "row.sh: h59w ${*:2}: holds 1-3 perf and 4 quality take no other argument; a spare 5 or 6 names the hold it repeats" >&2; exit 2 ;; + esac + E=(G="${W}/h59" LOCK="${W}/inner.lock" K3D_REAL="${W}/bin/k3d" H59_IMAGE="${IMAGE}") + ARGV=(bash "${REPO}/bench/h59/hold.sh" wildct "${@:2}") + [ "${WILDCT_ROW_PRINT:-0}" = 1 ] || cmp -s "${REPO}/bench/h59/k3d-shim.sh" "${W}/h59/bin/k3d" \ + || { echo "row.sh: ${W}/h59/bin/k3d is not bench/h59/k3d-shim.sh (install it first)" >&2; exit 2; } ;; + h59w-prep) + # prep.sh takes the host lock itself for the conversion, so it never runs inside hold.sh. + [ -z "${WILDCT_HOLD:-}" ] || { echo "row.sh: h59w-prep runs outside hold.sh" >&2; exit 2; } + E=(G="${W}/h59" LOCK="${W}/live.lock" PY_HF="${W}/hfvenv/bin/python") + ARGV=(bash "${REPO}/bench/h59/prep.sh" wildct) ;; *) echo "row.sh: unknown row ${row}" >&2; exit 2 ;; esac [ "${WILDCT_ROW_CHECK:-0}" != 1 ] || exit 0 diff --git a/canary/README.md b/canary/README.md new file mode 100644 index 00000000..bf00314f --- /dev/null +++ b/canary/README.md @@ -0,0 +1,98 @@ +# Canary + +Every engine or node-image bump, whether a bot or a person opened it, is checked at +runtime before it merges. Engines move fast: a vLLM bug today is fixed tomorrow, and a +flag the launcher passes today is renamed tomorrow. Before this canary, no CI job built the +node image, started a cluster, or pulled and served an engine. Every engine path in the +tests was a stub. + +`.github/workflows/canary.yml` (name `Canary`) runs at 03:29 UTC every night, on pull +requests that touch a pin or the launcher, and on dispatch (`engines`: ids or `auto`; +`attempt`). It runs only on GitHub-hosted runners. It has read-only permissions and no +secrets. + +| Kind | Engines | What it proves | +|---|---|---| +| runtime | `vllm-cpu`, `ovms-cpu`, `llama-cpu`; `eat-vllm-cpu` (informational) | The pinned image serves a pinned Qwen3-0.6B the way the launcher runs it. It returns one completion (HTTP 200, `finish_reason` stop or length, tokens > 0, the text matches `/Paris/`) and one stream (at least 2 chunks, then `[DONE]`), and the server reports the version its tag names. | +| surface | `vllm-xpu`, `llm-scaler`, `gaudi-vllm`, `llama-sycl`, `llama-vulkan`, `llama-full`, `dlstreamer`, `sglang-xeon`, `npu-umd`, `ovms` (and the runtime engines first) | Every flag and env name the launcher and `console/model_opt.py` emit (`canary/flags.sh`) exists in the image, and the constants derived from it still match: `console/plan.py`'s vLLM versions, and the NPU driver and firmware date. No device is needed. | +| node | `node` | The root Dockerfile builds. `k3d cluster create --image` it with the k3d `packaging/install.sh` pins and the arguments the launcher passes on a host without NVIDIA (no load balancer, no traefik, the node labelled `nvidia.com/gpu.present=false`). Within 5 minutes the node is Ready, every baked manifest is in place, every DaemonSet they define exists, and every pod in every namespace (kube-system, NFD, the device plugins) is Running and ready or Succeeded; then the pods hold for 20 s with no container restarting. A plugin that needs hardware gets no pod on a hosted runner (its node selector), so a pod still Pending at the timeout, such as one whose image does not pull, fails. | + +## Files + +| File | Role | +|---|---| +| `engines.toml` | One table per engine. Its `locator` points at the pin in the tree (never a copy of it), plus the model and revision, the launcher-equivalent arguments, the golden, the version probe, the flag sources and the derived constants. | +| `plan.py` | Resolves the pins, plans the job matrix and merges the report. | +| `run.sh` | Runtime and surface: disk, CPU flags, model, size, pull, surface, serve, probe, ISA. | +| `probe.py` | The HTTP checks against a serving engine. | +| `surface.py` | The flag, env and derived-constant check. It reads the engine's own `--help`, or for vLLM statically parses `arg_utils.py`, `entrypoints/**/cli_args.py` and `envs.py` inside the image, without importing them. | +| `flags.sh` | Lists the names k3d-gpu emits, per engine family. | +| `node.sh` | The node canary. | +| `goldens/.json` | `{prompt, regex, exact, recorded_with}` for each runtime engine. | + +## How names and versions compare + +- A flag matches exactly; for vLLM, `--a_b` and `--a-b` are one flag. +- sglang's parser is argparse with abbreviations allowed, and `bench/h54` passes `--tp`. + So for sglang, a long flag also matches when it begins exactly one option (`--tp` is + `--tp-size`). Once a second option begins with it, argparse calls it ambiguous, and so + does the canary. vLLM flags never match by prefix, so a renamed flag cannot hide behind + a longer one. +- A derived constant matches the image's version by release (`0.32.0` = `0.32.0+cpu`). + A setuptools-scm build of a tag with patches applied, `X.Y.(Z+1).dev0+g.d`, + is release `X.Y.Z`: intel/llm-scaler-vllm 1.5 reports `0.26.1.dev0+g568afb3a1.d20260907.xpu`, + which is vLLM v0.26.0's commit with Intel's patches. A build commits after a tag + (`.dev1` and up) matches nothing. + +## Which engines run + +`schedule` runs every engine. + +A pull request or `engines=auto` runs the engines that differ from +`merge-base(origin/main, HEAD)` in any of these: + +- the resolved image reference; +- their `engines.toml` table; +- their golden; +- a derived constant; +- a flag `flags.sh` names for them. + +The node engine also runs when the Dockerfile, `share/` or `packaging/install.sh` change. A +change to the harness itself runs every engine. + +`engines=a,b` runs exactly those engines. + +## Verdicts (`canary-result` artifact, `canary-result.json`) + +```json +{"sha": "...", "ref": "...", "engines": {"": {"kind": "runtime|surface|node", + "pinned_ref": "...", "verdict": "pass|fail|isa-skip|too-big|unparseable", + "hard": true, "exact": true, "cpu_flags": ["avx2"], "detail": "..."}}} +``` + +Only `verdict` pass with `hard` true is green. The report job fails unless every selected +non-informational engine is green. + +| Value | Meaning | +|---|---| +| `exact` false | The text differs from the golden, but the hard checks passed. It is still green, but it is not evidence for an automatic merge. | +| `isa-skip` | The runner's CPU lacks an instruction set the engine needs: no `avx512f`, and the engine's `isa_signal` in its log, or SIGILL. The workflow dispatches itself again, up to `attempt` 3. Hosted runners vary in CPU, so a later attempt may land on one with AVX-512. | +| `too-big` | The image needs more disk than the runner has, even after the toolchains are deleted. | +| `unparseable` | A help output or a source the surface check could not read. It is red, never skipped. | + +## Recording a golden + +`exact` is the completion text the hosted runner produced with temperature 0, top_k 1 and +seed 0. Take it from the `detail` (`text '...'`) of a green run, and write it with the +image, model and CPU it was recorded on (the job log's `cpu:` line names the CPU). A `null` +`exact` means nothing is recorded yet. The vLLM goldens come from an AVX2-only runner; a +runner with AVX-512 runs other kernels and may produce other text. That clears `exact`, +which sends the bump to review, but it is never red. + +## Adding an engine + +1. Add an `[[engine]]` with a `locator` that matches exactly one reference. + `tests/canary_spec_test.sh` checks it against the tree. +2. If the launcher emits flags for a new family, teach `flags.sh` its source. A flag + literal in `console/model_opt.py` or `console/fit.py` that no rule attributes to an + engine fails `flags.sh`. diff --git a/canary/engines.toml b/canary/engines.toml new file mode 100644 index 00000000..741cb95d --- /dev/null +++ b/canary/engines.toml @@ -0,0 +1,257 @@ +# The engines the canary checks (.github/workflows/canary.yml, canary/README.md). +# +# Each [[engine]] names where its image is pinned in the tree, never the pin itself, +# so a bump (bot or human) moves the canary with it. Fields: +# id the engine id canary-result.json and `engines=` use +# kind runtime: serve one real and one streamed completion (canary/run.sh), after +# the surface check when it has a `surface` +# surface: check the flags, env names and derived constants (canary/surface.py) +# node: build the node image and start a cluster on it (canary/node.sh) +# family the canary/flags.sh engine family whose names apply to it +# informational a runtime engine whose result never fails the report (an EAT pin) +# locator {file, regex}: the regex matches exactly one image reference in file, +# as a named group `ref` (name@sha256 or name:tag@sha256), or as groups +# `tag` and `digest` with a fixed `name` +# model, revision, files +# the Hugging Face repo, its 40-hex commit and the files a runtime engine +# serves (fetched at that commit, cached by actions/cache) +# args, env the engine's arguments and environment, as the launcher (or the fit, +# for vLLM on the CPU) sets them for this model; {name} is the served +# model name, {mount} the model's path in the container, {model} the +# repo id, {threads} the runner's CPU count +# mount where the model directory is mounted (read-only); with `writable`, a copy +# of it is, inside a writable directory mounted there +# prepare, prepare_env +# a run before the server's, on the same mounts (OVMS: --pull writes graph.pbtxt) +# cap_add, shm_size +# what the launcher's pod gets (IPC_LOCK for mlock, the shm emptyDir) +# api, port the OpenAI API root and the container port +# golden canary/goldens/.json: prompt, the hard regex and the exact text +# version_probe how the engine reports its version: {path, key, regex} (a server +# endpoint), {args[, entrypoint], regex} (a command in the image) or +# {package} (the installed package); `tag` reads the expected version +# from the pinned tag (a pin without a tag is not compared) +# surface {driver, ...}: how canary/surface.py reads the image's flags (see there) +# flags_source the canary/flags.sh sources (of `family`) whose names this image must accept +# derived constants elsewhere in the tree that must equal a fact of the image +# ({file, regex with group v, of}; `of` names the image fact: package, +# deb, firmware) +# isa_signal runtime: a log regex that, on a runner without avx512f, makes the +# verdict isa-skip instead of fail (the image cannot run on this ISA) +# paths node: files and directories whose change selects it + +[defaults] +ready_timeout = 600 # seconds the server gets to list its model at {api}/models +size_factor = 3.5 # pulled and unpacked bytes per compressed byte (too-big check) +name = "canary" # served model name + +# ---- runtime --------------------------------------------------------------------- + +[[engine]] +id = "vllm-cpu" +kind = "runtime" +family = "vllm" +locator = { file = "scripts/k3d-gpu", regex = 'AMX_TEST_IMAGE="\$\{K3D_GPU_AMX_TEST_IMAGE:-(?Pvllm/vllm-openai-cpu(?::[^@"}]+)?@sha256:[0-9a-f]{64})\}"' } +model = "Qwen/Qwen3-0.6B" +revision = "c1899de289a04d12100db370d81485cdf75e47ca" +files = ["config.json", "generation_config.json", "merges.txt", "model.safetensors", "tokenizer.json", "tokenizer_config.json", "vocab.json"] +mount = "/models/model" +# vLLM on the CPU as the fit sizes it (console/fit.py _flags): --max-model-len, +# --max-num-seqs and VLLM_CPU_KVCACHE_SPACE (GiB). +args = ["{mount}", "--served-model-name={name}", "--port=8000", "--dtype=bfloat16", "--max-model-len=2048", "--max-num-seqs=1"] +env = ["VLLM_CPU_KVCACHE_SPACE=1"] +shm_size = "2g" # the pod's shm emptyDir (vllm_manifest): worker IPC +api = "/v1" +port = 8000 +golden = "canary/goldens/vllm-cpu.json" +version_probe = { path = "/version", key = "version", regex = '^(?P\d+\.\d+\.\d+)', tag = '^v(?P\d+\.\d+\.\d+)$' } +surface = { driver = "vllm" } +flags_source = ["scripts/k3d-gpu:vllm_manifest", "console/fit.py:_flags", "console/model_opt.py:optimize", "console/model_opt.py:spec_decide", "console/model_opt.py:eat_patch"] +isa_signal = 'Illegal instruction|Failed to import from vllm\._C_AVX2' + +[[engine]] +id = "ovms-cpu" +kind = "runtime" +family = "ovms" +locator = { file = "scripts/k3d-gpu", regex = 'AGENT_OVMS_IMAGE="(?Popenvino/model_server(?::[^@"]+)?@sha256:[0-9a-f]{64})"' } +model = "OpenVINO/Qwen3-0.6B-int8-ov" +revision = "ac8e917fb8350f528ada41b02c60ea3a2853955f" +files = ["added_tokens.json", "config.json", "generation_config.json", "merges.txt", "openvino_detokenizer.bin", "openvino_detokenizer.xml", "openvino_model.bin", "openvino_model.xml", "openvino_tokenizer.bin", "openvino_tokenizer.xml", "special_tokens_map.json", "tokenizer.json", "tokenizer_config.json", "vocab.json"] +# agent_manifest's pull init container and server, on the CPU plugin. The model is +# already at {mount} (pinned revision), so --pull only writes graph.pbtxt; HF_ENDPOINT +# points nowhere so it can never fetch another revision. +mount = "/models/{model}" +writable = "/models" +prepare = ["--pull", "--source_model={model}", "--model_repository_path=/models", "--model_name={name}", "--task=text_generation", "--target_device=CPU"] +prepare_env = ["HF_ENDPOINT=http://127.0.0.1:9"] +args = ["--rest_port=8000", "--model_path={mount}", "--model_name={name}", "--cache_dir=/models/.ov_cache", "--log_level=INFO"] +api = "/v3" +port = 8000 +golden = "canary/goldens/ovms-cpu.json" +version_probe = { path = "/v2", key = "version", regex = '^(?P\d+\.\d+\.\d+)', tag = '^(?P\d+\.\d+\.\d+)' } +flags_source = ["scripts/k3d-gpu:agent_manifest", "scripts/k3d-gpu:agent_ovms_addon_manifest", "console/fit.py:_flags", "console/model_opt.py:spec_decide"] + +[[engine]] +id = "llama-cpu" +kind = "runtime" +family = "llama.cpp" +locator = { file = "scripts/k3d-gpu", regex = 'LLAMA_CPU_IMAGE="\$\{K3D_GPU_LLAMA_CPU_IMAGE:-(?Pghcr\.io/ggml-org/llama\.cpp(?::[^@"}]+)?@sha256:[0-9a-f]{64})\}"' } +model = "ggml-org/Qwen3-0.6B-GGUF" +revision = "13ac06624948e4d8589cb4b6f90100837342fcb8" +files = ["Qwen3-0.6B-Q8_0.gguf"] +mount = "/models" +# llamacpp_manifest on a CPU placement: the fit's -c/-np/-ngl, threads, mmap+mlock +# (with IPC_LOCK, as the pod gets), thinking off. +args = ["--model", "{mount}/Qwen3-0.6B-Q8_0.gguf", "--alias", "{name}", "--host", "0.0.0.0", "--port", "8000", "--jinja", "--reasoning", "off", "-c", "2048", "-np", "1", "-ngl", "0", "--threads", "{threads}", "--threads-batch", "{threads}", "--load-mode", "mmap+mlock"] +cap_add = ["IPC_LOCK"] +api = "/v1" +port = 8000 +golden = "canary/goldens/llama-cpu.json" +version_probe = { path = "/props", key = "build_info", regex = '^b(?P\d+)', tag = 'b(?P\d+)$' } +surface = { driver = "llama-server" } +flags_source = ["scripts/k3d-gpu:llamacpp_manifest", "console/fit.py:_flags", "console/fit.py:fit_groups", "console/model_opt.py:spec_decide"] + +[[engine]] +id = "eat-vllm-cpu" +kind = "runtime" +family = "vllm" +informational = true +locator = { file = "integrations/eat/vllm-values.yaml", name = "vllm/vllm-openai-cpu", regex = 'repository: vllm/vllm-openai-cpu\n tag: "(?P[^"@]+)@(?Psha256:[0-9a-f]{64})"' } +model = "Qwen/Qwen3-0.6B" +revision = "c1899de289a04d12100db370d81485cdf75e47ca" +files = ["config.json", "generation_config.json", "merges.txt", "model.safetensors", "tokenizer.json", "tokenizer_config.json", "vocab.json"] +mount = "/models/model" +args = ["{mount}", "--served-model-name={name}", "--port=8000", "--dtype=bfloat16", "--max-model-len=2048", "--max-num-seqs=1"] +env = ["VLLM_CPU_KVCACHE_SPACE=1"] +shm_size = "2g" # the pod's shm emptyDir (vllm_manifest): worker IPC +api = "/v1" +port = 8000 +golden = "canary/goldens/eat-vllm-cpu.json" +version_probe = { path = "/version", key = "version", regex = '^(?P\d+\.\d+\.\d+)', tag = '^v(?P\d+\.\d+\.\d+)$' } +surface = { driver = "vllm" } +# model_opt.py's eat_patch adds these to EAT's vLLM modelConfigs entry. +flags_source = ["console/model_opt.py:eat_patch", "console/model_opt.py:optimize", "console/model_opt.py:spec_decide"] +isa_signal = 'Illegal instruction|Failed to import from vllm\._C_AVX2' + +# ---- node ------------------------------------------------------------------------ + +[[engine]] +id = "node" +kind = "node" +locator = { file = "Dockerfile", name = "rancher/k3s", regex = 'ARG K3S_TAG="(?P[^"]+)"\nARG K3S_DIGEST="(?Psha256:[0-9a-f]{64})"' } +# Besides its k3s pin, the node is selected when these change. +paths = ["Dockerfile", "share/", "packaging/install.sh"] + +# ---- surface --------------------------------------------------------------------- + +[[engine]] +id = "vllm-xpu" +kind = "surface" +family = "vllm" +locator = { file = "scripts/k3d-gpu", regex = 'AGENT_DEFAULT_IMAGE="(?Pvllm/vllm-openai-xpu(?::[^@"]+)?@sha256:[0-9a-f]{64})"' } +version_probe = { package = "vllm", tag = '^v(?P\d+\.\d+\.\d+)$' } +surface = { driver = "vllm" } +flags_source = ["scripts/k3d-gpu:vllm_manifest", "scripts/k3d-gpu:VLLM_XPU_ENV", "console/fit.py:_flags", "console/model_opt.py:optimize", "console/model_opt.py:spec_decide"] +derived = [ + { file = "console/plan.py", regex = 'AGENT_VLLM_VERSION = \{[^}]*"GPU": "(?P[0-9.]+)"', of = "package" }, + { file = "console/plan.py", regex = 'AGENT_VLLM_VERSION\.get\(device, "(?P[0-9.]+)"\)', of = "package" }, + { file = "console/model_opt.py", regex = '\(o\.get\("vllm_version"\) or "(?P[0-9.]+)"\)', of = "package" }, + { file = "scripts/k3d-gpu", regex = 'device=\$\{AGENT_SEL%% \*\} vver=(?P[0-9.]+) ', of = "package" }, +] + +[[engine]] +id = "llm-scaler" +kind = "surface" +family = "vllm" +locator = { file = "scripts/k3d-gpu", regex = 'AGENT_DEFAULT_IMAGE="(?Pintel/llm-scaler-vllm(?::[^@"]+)?@sha256:[0-9a-f]{64})"' } +version_probe = { package = "vllm" } +surface = { driver = "vllm" } +flags_source = ["scripts/k3d-gpu:vllm_manifest", "scripts/k3d-gpu:VLLM_XPU_ENV", "console/fit.py:_flags", "console/model_opt.py:optimize", "console/model_opt.py:spec_decide"] +derived = [ + { file = "console/plan.py", regex = '"(?P[0-9.]+)" if backend == "llm-scaler"', of = "package" }, + { file = "scripts/k3d-gpu", regex = 'llm-scaler\) vver=(?P[0-9.]+) ;;', of = "package" }, +] + +[[engine]] +id = "gaudi-vllm" +kind = "surface" +family = "vllm" +locator = { file = "scripts/k3d-gpu", regex = 'AGENT_DEFAULT_IMAGE="(?Pvault\.habana\.ai/gaudi-docker/[^@":]+(?::[^@"]+)?@sha256:[0-9a-f]{64})"' } +version_probe = { package = "vllm" } +surface = { driver = "vllm" } +flags_source = ["scripts/k3d-gpu:vllm_manifest", "scripts/k3d-gpu:VLLM_HPU_ENV", "console/model_opt.py:optimize", "console/model_opt.py:spec_decide"] +derived = [ + { file = "console/plan.py", regex = 'AGENT_VLLM_VERSION = \{[^}]*"HPU": "(?P[0-9.]+)"', of = "package" }, + { file = "scripts/k3d-gpu", regex = '\[ -z "\$\{AGENT_HPU:-\}" \] \|\| vver=(?P[0-9.]+)', of = "package" }, +] + +[[engine]] +id = "llama-sycl" +kind = "surface" +family = "llama.cpp" +locator = { file = "scripts/k3d-gpu", regex = 'LLAMA_SYCL_IMAGE="\$\{K3D_GPU_LLAMA_SYCL_IMAGE:-(?Pghcr\.io/ggml-org/llama\.cpp(?::[^@"}]+)?@sha256:[0-9a-f]{64})\}"' } +version_probe = { args = ["--version"], regex = 'build (?P\d+)', tag = 'b(?P\d+)$' } +surface = { driver = "llama-server" } +flags_source = ["scripts/k3d-gpu:llamacpp_manifest", "console/fit.py:_flags", "console/fit.py:fit_groups", "console/model_opt.py:spec_decide"] + +[[engine]] +id = "llama-vulkan" +kind = "surface" +family = "llama.cpp" +locator = { file = "scripts/k3d-gpu", regex = 'LLAMA_VULKAN_IMAGE="\$\{K3D_GPU_LLAMA_VULKAN_IMAGE:-(?Pghcr\.io/ggml-org/llama\.cpp(?::[^@"}]+)?@sha256:[0-9a-f]{64})\}"' } +version_probe = { args = ["--version"], regex = 'build (?P\d+)', tag = 'b(?P\d+)$' } +surface = { driver = "llama-server" } +flags_source = ["scripts/k3d-gpu:llamacpp_manifest", "console/fit.py:_flags", "console/fit.py:fit_groups", "console/model_opt.py:spec_decide"] + +[[engine]] +id = "llama-full" +kind = "surface" +family = "llama-convert" +locator = { file = "scripts/k3d-gpu", regex = 'LLAMA_FULL_IMAGE="\$\{K3D_GPU_LLAMA_FULL_IMAGE:-(?Pghcr\.io/ggml-org/llama\.cpp(?::[^@"}]+)?@sha256:[0-9a-f]{64})\}"' } +version_probe = { entrypoint = "/app/llama-cli", args = ["--version"], regex = 'build (?P\d+)', tag = 'b(?P\d+)$' } +surface = { driver = "llama-convert" } +flags_source = ["scripts/k3d-gpu:gguf_convert", "scripts/k3d-gpu:llamacpp_manifest", "console/fit.py:convert_plan"] + +[[engine]] +id = "dlstreamer" +kind = "surface" +family = "dlstreamer" +locator = { file = "scripts/k3d-gpu", regex = 'AGENT_VIDEO_IMAGE="\$\{K3D_GPU_AGENT_VIDEO_IMAGE:-(?Pintel/dlstreamer(?::[^@"}]+)?@sha256:[0-9a-f]{64})\}"' } +version_probe = { entrypoint = "gst-inspect-1.0", args = ["--version"], regex = 'GStreamer (?P[0-9.]+)' } +# va* elements register only on a VA device (GStreamer's va plugin enumerates +# /dev/dri), which a hosted runner has none of: they are listed, not checked. +surface = { driver = "gst", device_only = ["va"] } +flags_source = ["scripts/k3d-gpu:agent_video_script"] + +[[engine]] +id = "sglang-xeon" +kind = "surface" +family = "sglang" +locator = { file = "console/moe_predict.py", regex = '"images": \("(?Plmsysorg/sglang(?::[^@"]+)?@sha256:[0-9a-f]{64})",\)' } +version_probe = { package = "sglang", tag = '^v(?P\d+\.\d+\.\d+)' } +surface = { driver = "sglang" } +flags_source = ["bench/h54/h54.py:sglang_args"] + +[[engine]] +id = "npu-umd" +kind = "surface" +family = "ovms" +locator = { file = "scripts/k3d-gpu", regex = 'NPU_UMD_IMAGE="(?Popenvino/model_server(?::[^@"]+)?@sha256:[0-9a-f]{64})"' } +version_probe = { args = ["--version"], regex = 'OpenVINO Model Server (?P\d+\.\d+\.\d+)', tag = '^(?P\d+\.\d+\.\d+)' } +# The NPU user space k3d-gpu checks host firmware against (npu_doctor): the driver +# package's version and the build date inside its firmware. +surface = { driver = "ovms", deb = "intel-level-zero-npu", firmware = "/lib/firmware/updates/intel/vpu/vpu_37xx_v1.bin" } +flags_source = ["scripts/k3d-gpu:agent_manifest", "scripts/k3d-gpu:agent_ovms_addon_manifest", "console/fit.py:_flags", "console/model_opt.py:spec_decide"] +derived = [ + { file = "scripts/k3d-gpu", regex = 'NPU_UMD_DRIVER="v(?P[0-9.]+)"', of = "deb" }, + { file = "scripts/k3d-gpu", regex = 'NPU_UMD_FW_DATE="(?P[0-9]{8})"', of = "firmware" }, +] + +[[engine]] +id = "ovms" +kind = "surface" +family = "ovms" +locator = { file = "scripts/k3d-gpu", regex = 'AGENT_OVMS_IMAGE="(?Popenvino/model_server(?::[^@"]+)?@sha256:[0-9a-f]{64})"' } +version_probe = { args = ["--version"], regex = 'OpenVINO Model Server (?P\d+\.\d+\.\d+)', tag = '^(?P\d+\.\d+\.\d+)' } +surface = { driver = "ovms" } +flags_source = ["scripts/k3d-gpu:agent_manifest", "scripts/k3d-gpu:agent_ovms_addon_manifest", "console/fit.py:_flags", "console/model_opt.py:spec_decide"] diff --git a/canary/flags.sh b/canary/flags.sh new file mode 100755 index 00000000..01dfdae8 --- /dev/null +++ b/canary/flags.sh @@ -0,0 +1,244 @@ +#!/usr/bin/env bash +# The engine flags and environment names k3d-gpu emits, per engine family, for the +# canary's surface check (canary/surface.py asserts each one exists in the image). +# +# canary/flags.sh [--root DIR] [FAMILY...] +# +# Prints one tab-separated line per name, sorted and unique: +# +# family vllm | ovms | llama.cpp | llama-convert | dlstreamer | sglang +# source where the name is emitted, : (console/*.py: :) +# kind flag | env | element | property +# name --flag or -f (no value), VLLM_* env name, GStreamer element, element.property +# +# Sources: +# scripts/k3d-gpu vllm_manifest (and VLLM_XPU_ENV, VLLM_HPU_ENV), agent_manifest (OVMS), +# llamacpp_manifest, gguf_convert, llamacpp_convert_job callers, +# agent_video_script (DL Streamer pipeline) +# console/model_opt.py, console/fit.py +# the optimizer's and the fit's engine arguments (agent_opt_items and +# AGENT_LLAMA_FLAGS feed them to the manifests): each string literal +# that starts with - in a list or an .append() argument, attributed to +# the engine of the innermost `engine == "X"` / `eng == "X"` test, or of +# the one engine left after earlier `if engine == "X": ... return` +# branches; FUNCTION_FAMILY names the functions with no engine test +# bench/h54/h54.py sglang_args, the only sglang command line in the repo (read only) +# A flag literal no rule attributes is an error, so a new emitter cannot go unchecked. + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +if [ "${1:-}" = --root ]; then + [ $# -ge 2 ] || { echo "flags.sh: --root needs a directory" >&2; exit 2; } + ROOT=$2 + shift 2 +fi +LAUNCHER=${ROOT}/scripts/k3d-gpu +[ -f "${LAUNCHER}" ] || { echo "flags.sh: no ${LAUNCHER}" >&2; exit 1; } + +# fn_body : the launcher function's lines, comments dropped. +fn_body() { + awk -v f="$1" '$0 ~ "^"f"\\(\\) \\{" {p = 1} p {print} p && /^}/ {exit}' "${LAUNCHER}" \ + | grep -vE '^[[:space:]]*#' || true +} + +# array_body : the items of a launcher array NAME=( ... ). +array_body() { + awk -v a="$1" '$0 ~ "^"a"=\\($" {p = 1; next} p && /^\)/ {exit} p {print}' "${LAUNCHER}" \ + | grep -vE '^[[:space:]]*#' || true +} + +FLAG_RE='--?[A-Za-z][A-Za-z0-9_-]*' + +# shell_flags: flag words a manifest function puts in the server's argument list: +# "${item}--flag", a YAML list line "- --flag", llama_items arguments and the fit's +# default flags="..." string. +shell_flags() { + local body + body=$(cat) + { + grep -oE "\\\$\\{item\\}'?${FLAG_RE}" <<< "${body}" | sed -E "s/^\\\$\\{item\\}'?//" + grep -E '^[[:space:]]+- -' <<< "${body}" | sed -E "s/^[[:space:]]+- (${FLAG_RE}).*/\\1/" + grep -oE "\\\\n[[:space:]]+- ${FLAG_RE}" <<< "${body}" | sed -E 's/^\\n[[:space:]]+- //' + grep -oE 'llama_items [^)]*' <<< "${body}" | grep -oE "(^|[[:space:]\"])${FLAG_RE}" + grep -oE 'flags="-[^"]*"' <<< "${body}" | grep -oE "(^|[[:space:]\"])${FLAG_RE}" + } | tr -d ' "' | grep -xE -- "${FLAG_RE}" || true +} + +emit() { # emit : one line per name on stdin + local n + while IFS= read -r n; do + [ -z "${n}" ] || printf '%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "${n}" + done +} + +{ + fn_body vllm_manifest | shell_flags | emit vllm scripts/k3d-gpu:vllm_manifest flag + for a in VLLM_XPU_ENV VLLM_HPU_ENV; do + array_body "${a}" | sed -nE 's/^[[:space:]]*"?(VLLM_[A-Z0-9_]+)=.*/\1/p' | emit vllm "scripts/k3d-gpu:${a}" env + done + fn_body agent_manifest | shell_flags | emit ovms scripts/k3d-gpu:agent_manifest flag + fn_body agent_ovms_addon_manifest | shell_flags | emit ovms scripts/k3d-gpu:agent_ovms_addon_manifest flag + fn_body llamacpp_manifest | shell_flags | emit llama.cpp scripts/k3d-gpu:llamacpp_manifest flag + # The conversion's own flags: gguf_convert's convert_hf_to_gguf.py call, and the + # arguments llamacpp_manifest passes to llamacpp_convert_job. + fn_body gguf_convert | sed -nE '/convert_hf_to_gguf\.py/{s/(^|[[:space:]"])(--[A-Za-z][A-Za-z0-9_-]*)/\n\2\n/g; p}' \ + | grep -xE -- "${FLAG_RE}" | emit llama-convert scripts/k3d-gpu:gguf_convert flag + fn_body llamacpp_manifest | sed -nE '/llamacpp_convert_job /{s/.*"(--[^"]*)"\).*/\1/p}' \ + | tr ' ' '\n' | grep -xE -- "${FLAG_RE}" | emit llama-convert scripts/k3d-gpu:llamacpp_manifest flag + python3 - "${ROOT}" <<'PY' +import ast +import re +import sys + +root = sys.argv[1] +FLAG = re.compile(r"^(--?[A-Za-z][A-Za-z0-9_-]*)") +ENVV = re.compile(r"^(VLLM_[A-Z0-9_]+)=") +ENGINES = {"vllm", "ovms", "llama.cpp"} +# Functions that emit engine arguments with no engine test of their own. +FUNCTION_FAMILY = { + ("console/model_opt.py", "eat_patch"): "vllm", # EAT's vLLM modelConfigs entry + ("console/fit.py", "convert_plan"): "llama-convert", # convert_hf_to_gguf.py arguments + ("console/fit.py", "fit_groups"): "llama.cpp", # -hf for a GGUF repo with several models +} +bad = [] + + +def eng_test(t): + if isinstance(t, ast.BoolOp) and isinstance(t.op, ast.And): + for v in t.values: + e = eng_test(v) + if e: + return e + return None + if (isinstance(t, ast.Compare) and len(t.ops) == 1 and isinstance(t.ops[0], ast.Eq) + and isinstance(t.left, ast.Name) and t.left.id in ("engine", "eng") + and isinstance(t.comparators[0], ast.Constant) and t.comparators[0].value in ENGINES): + return t.comparators[0].value + return None + + +def lead(e): + """The literal text an argument starts with: "x", "x" % y, f"x{y}".""" + if isinstance(e, ast.Constant) and isinstance(e.value, str): + return e.value + if isinstance(e, ast.BinOp) and isinstance(e.op, ast.Mod): + return lead(e.left) + if isinstance(e, ast.JoinedStr) and e.values: + return lead(e.values[0]) + return None + + +def literals(node): + out = [] + for n in ast.walk(node): + if isinstance(n, ast.List): + out += [(lead(e), e.lineno) for e in n.elts] + elif (isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) and n.func.attr == "append" + and len(n.args) == 1): + out.append((lead(n.args[0]), n.args[0].lineno)) + return [(v, ln) for v, ln in out if v is not None] + + +def scan(rel): + found = [] + + def expr(node, fn, eng, excluded): + for v, ln in literals(node): + m, env = FLAG.match(v), ENVV.match(v) + if not m and not env: + continue + who = eng or FUNCTION_FAMILY.get((rel, fn)) + if who is None and len(ENGINES - excluded) == 1: + who = next(iter(ENGINES - excluded)) + if who is None: + bad.append("%s:%d (%s) %s" % (rel, ln, fn, v)) + continue + found.append((who, "%s:%s" % (rel, fn), "env" if env else "flag", (env or m).group(1))) + + def block(stmts, fn, eng, excluded): + excluded = set(excluded) + for s in stmts: + if isinstance(s, (ast.FunctionDef, ast.AsyncFunctionDef)): + block(s.body, s.name, None, set()) + elif isinstance(s, ast.ClassDef): + block(s.body, fn, eng, excluded) + elif isinstance(s, ast.If): + e = eng_test(s.test) + pure = e is not None and isinstance(s.test, ast.Compare) + block(s.body, fn, e or eng, excluded) + block(s.orelse, fn, eng, excluded | ({e} if pure else set())) + if pure and s.body and isinstance(s.body[-1], ast.Return): + excluded.add(e) + elif isinstance(s, (ast.For, ast.AsyncFor, ast.While, ast.With, ast.AsyncWith, ast.Try)): + for f in ("body", "orelse", "finalbody"): + block(getattr(s, f, None) or [], fn, eng, excluded) + for h in getattr(s, "handlers", None) or []: + block(h.body, fn, eng, excluded) + else: + expr(s, fn, eng, excluded) + + with open("%s/%s" % (root, rel), encoding="utf-8") as f: + block(ast.parse(f.read()).body, "", None, set()) + return found + + +rows = scan("console/model_opt.py") + scan("console/fit.py") + +# sglang: bench/h54/h54.py sglang_args() is the repo's sglang command line; its flags +# are the words after the module name (python3 -m sglang.launch_server ...). +try: + tree = ast.parse(open("%s/bench/h54/h54.py" % root, encoding="utf-8").read()) + for fn in ast.walk(tree): + if isinstance(fn, ast.FunctionDef) and fn.name == "sglang_args": + words = [v for v, _ in literals(fn)] + mods = [i for i, v in enumerate(words) if v.endswith("launch_server")] + for v in words[mods[0] + 1:] if mods else []: + m = FLAG.match(v) + if m: + rows.append(("sglang", "bench/h54/h54.py:sglang_args", "flag", m.group(1))) +except FileNotFoundError: + pass + +# DL Streamer: the elements and properties of agent_video_script's gst-launch pipeline, +# with ${src}, ${decode} and ${pre} expanded to every value the script assigns them. +text = open("%s/scripts/k3d-gpu" % root, encoding="utf-8").read() +m = re.search(r"^agent_video_script\(\) \{\n(.*?)^\}", text, re.M | re.S) +if m: + body = m.group(1) + assigned = {} + for k, v in re.findall(r"\b(\w+)=\"([^\"]*)\"", body): + assigned.setdefault(k, []).append(v) + pipe = re.search(r"gst-launch-1\.0 -e (.*?[^\\])\n", re.sub(r"\\\n\s*", " ", body)) + if pipe: + segments, props = [], [] + for seg in pipe.group(1).split("!"): + words = seg.split() + if not words: + continue + ref = re.fullmatch(r"\$\{(\w+)\}", words[0]) + if ref: + for alt in assigned.get(ref.group(1), []): + segments += [s.split() for s in alt.split("!") if s.split()] + continue + segments.append(words) + for words in segments: + el = words[0] + if "/" in el or not re.fullmatch(r"[a-z][a-z0-9_-]*", el): + continue # caps, not an element + rows.append(("dlstreamer", "scripts/k3d-gpu:agent_video_script", "element", el)) + for w in words[1:]: + ref = re.fullmatch(r"\$\{(\w+)\}", w) + for kv in (assigned.get(ref.group(1), []) if ref else [w]): + if "=" in kv: + rows.append(("dlstreamer", "scripts/k3d-gpu:agent_video_script", "property", + "%s.%s" % (el, kv.split("=", 1)[0]))) + +for r in rows: + print("\t".join(r)) +if bad: + sys.stderr.write("flags.sh: engine flag literals no rule attributes to an engine (name the function in FUNCTION_FAMILY):\n " + + "\n ".join(bad) + "\n") + sys.exit(1) +PY +} | if [ $# -gt 0 ]; then awk -F'\t' -v fams=" $* " 'index(fams, " " $1 " ")'; else cat; fi | LC_ALL=C sort -u diff --git a/canary/goldens/eat-vllm-cpu.json b/canary/goldens/eat-vllm-cpu.json new file mode 100644 index 00000000..08b88fbd --- /dev/null +++ b/canary/goldens/eat-vllm-cpu.json @@ -0,0 +1,12 @@ +{ + "prompt": "The capital of France is", + "regex": "Paris", + "exact": " Paris. The capital of France is also", + "recorded_with": { + "engine": "vllm/vllm-openai-cpu:v0.24.0 (0.24.0), --dtype=bfloat16, VLLM_CPU_KVCACHE_SPACE=1", + "model": "Qwen/Qwen3-0.6B@c1899de289a04d12100db370d81485cdf75e47ca", + "request": "POST /v1/completions temperature 0, top_k 1, seed 0, max_tokens 8", + "cpu": "GitHub-hosted ubuntu-latest runner (avx2, no avx512f), 4 CPUs; Canary run 40960775877", + "date": "2026-10-29" + } +} diff --git a/canary/goldens/llama-cpu.json b/canary/goldens/llama-cpu.json new file mode 100644 index 00000000..c6544904 --- /dev/null +++ b/canary/goldens/llama-cpu.json @@ -0,0 +1,12 @@ +{ + "prompt": "The capital of France is", + "regex": "Paris", + "exact": " Paris. The capital of Italy is Rome", + "recorded_with": { + "engine": "ghcr.io/ggml-org/llama.cpp:server-b11960 (build b11960-8642541b2)", + "model": "ggml-org/Qwen3-0.6B-GGUF@13ac06624948e4d8589cb4b6f90100837342fcb8 Qwen3-0.6B-Q8_0.gguf", + "request": "POST /v1/completions temperature 0, top_k 1, seed 0, max_tokens 8", + "cpu": "Intel Core i9-13900T (avx2, no avx512f), 2 CPUs", + "date": "2026-10-29" + } +} diff --git a/canary/goldens/ovms-cpu.json b/canary/goldens/ovms-cpu.json new file mode 100644 index 00000000..1107f633 --- /dev/null +++ b/canary/goldens/ovms-cpu.json @@ -0,0 +1,12 @@ +{ + "prompt": "The capital of France is", + "regex": "Paris", + "exact": " Paris. The capital of Italy is Rome", + "recorded_with": { + "engine": "openvino/model_server:2026.4.1-gpu (2026.4.1.cfc9ac721), CPU plugin", + "model": "OpenVINO/Qwen3-0.6B-int8-ov@ac8e917fb8350f528ada41b02c60ea3a2853955f", + "request": "POST /v3/completions temperature 0, top_k 1, seed 0, max_tokens 8", + "cpu": "Intel Core i9-13900T (avx2, no avx512f), 2 CPUs", + "date": "2026-10-29" + } +} diff --git a/canary/goldens/vllm-cpu.json b/canary/goldens/vllm-cpu.json new file mode 100644 index 00000000..c3abd780 --- /dev/null +++ b/canary/goldens/vllm-cpu.json @@ -0,0 +1,12 @@ +{ + "prompt": "The capital of France is", + "regex": "Paris", + "exact": " Paris. The capital of France is also", + "recorded_with": { + "engine": "vllm/vllm-openai-cpu:v0.32.0 (0.32.0), --dtype=bfloat16, VLLM_CPU_KVCACHE_SPACE=1", + "model": "Qwen/Qwen3-0.6B@c1899de289a04d12100db370d81485cdf75e47ca", + "request": "POST /v1/completions temperature 0, top_k 1, seed 0, max_tokens 8", + "cpu": "GitHub-hosted ubuntu-latest runner (avx2, no avx512f), 4 CPUs; Canary run 40960775877", + "date": "2026-10-29" + } +} diff --git a/canary/node.sh b/canary/node.sh new file mode 100755 index 00000000..9206ebcf --- /dev/null +++ b/canary/node.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# The node canary: build the node image from the root Dockerfile (Ubuntu 26.04, the +# default variant), start a k3d cluster on it with the k3d release packaging/install.sh +# pins (sha256-checked) and the arguments the launcher's cluster create passes on a host +# without NVIDIA (NODE_ARGS), and within CANARY_NODE_TIMEOUT seconds (default 300) see: +# - the node Ready; +# - every pod, in every namespace (kube-system, NFD, the device plugins), Running with +# its containers ready, or Succeeded. A plugin that needs hardware has no pod here +# (its node selector keeps it off a hosted runner), so a pod that exists must run: +# one still Pending at the timeout (an image that does not pull) fails; +# - every manifest the Dockerfile bakes into /var/lib/rancher/k3s/server/manifests; +# - every DaemonSet those baked manifests define (the device plugins, NFD); +# and then the pods hold for CANARY_NODE_SETTLE seconds (default 20) with no container +# restarting, so a container that crashes after it started is caught. Then delete the +# cluster. kubectl runs inside the node (k3s ships it), so nothing but docker and curl is +# needed on the host. CANARY_NODE_POLL (default 5) is the seconds between looks. +# +# Writes ${CANARY_OUT_DIR:-./canary-out}/node.json (kind node; pinned_ref the k3s pin) +# and node.log, and exits non-zero unless the verdict is pass. + +set -uo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "${HERE}/.." && pwd)" +OUT_DIR=${CANARY_OUT_DIR:-${PWD}/canary-out} +TIMEOUT=${CANARY_NODE_TIMEOUT:-300} +SETTLE=${CANARY_NODE_SETTLE:-20} +POLL=${CANARY_NODE_POLL:-5} +CLUSTER=canary +NODE=k3d-${CLUSTER}-server-0 +IMAGE_TAG=k3d-gpu-canary:node +# scripts/k3d-gpu's cluster create on a host without NVIDIA, as a hosted runner is: no +# load balancer, no traefik, and the node labelled nvidia.com/gpu.present=false, which +# keeps the baked NVIDIA plugin off it (with no driver it crash-loops). Both Intel stacks +# stay enabled, so every baked manifest is applied. tests/canary_spec_test.sh checks that +# the launcher still passes each of these. +NODE_ARGS=(--no-lb "--k3s-arg=--disable=traefik@server:0" "--k3s-node-label=nvidia.com/gpu.present=false@server:*;agent:*") +mkdir -p "${OUT_DIR}" +LOG=${OUT_DIR}/node.log +WORK=$(mktemp -d "${TMPDIR:-/tmp}/canary-node.XXXXXX") +: > "${LOG}" +K3D="" +cleanup() { + [ -z "${K3D}" ] || "${K3D}" cluster delete "${CLUSTER}" >> "${LOG}" 2>&1 + rm -rf "${WORK}" +} +trap cleanup EXIT +say() { echo "canary node: $*" >&2; } + +R_CPU="" +cpuflags=" $(grep -m1 '^flags' "${CANARY_CPUINFO:-/proc/cpuinfo}" | cut -d: -f2) " +for f in avx2 avx512f amx_tile; do [[ "${cpuflags}" == *" ${f} "* ]] && R_CPU+="${f} "; done +REF=$(python3 "${HERE}/plan.py" ref node 2>> "${LOG}") + +finish() { # finish + R_VERDICT=$1 R_DETAIL=$2 R_REF=${REF} R_CPU=${R_CPU} python3 - "${OUT_DIR}/node.json" <<'PY' +import json, os, sys +e = os.environ +ok = e["R_VERDICT"] == "pass" +res = {"kind": "node", "pinned_ref": e["R_REF"], "verdict": e["R_VERDICT"], "hard": ok, "exact": ok, + "cpu_flags": e["R_CPU"].split(), "detail": e["R_DETAIL"]} +with open(sys.argv[1], "w") as f: + json.dump(res, f, indent=1) + f.write("\n") +print("canary node: %s: %s" % (e["R_VERDICT"], e["R_DETAIL"])) +PY + [ "$1" = pass ] + exit +} +[ -n "${REF}" ] || finish fail "plan: the node's k3s pin does not resolve (see node.log)" + +# The node image, as the release job builds its default variant. +start=${SECONDS} +say "docker buildx build --load -t ${IMAGE_TAG} ." +docker buildx build --load -t "${IMAGE_TAG}" "${ROOT}" >> "${LOG}" 2>&1 \ + || finish fail "the node image did not build: $(grep -E 'ERROR|error:' "${LOG}" | tail -3 | tr '\n' ' ')" +built=$((SECONDS - start)) + +# k3d at the version and sha256 packaging/install.sh pins. +ver=$(sed -n 's/^K3D_VERSION=//p' "${ROOT}/packaging/install.sh") +sha=$(sed -n 's/^K3D_SHA256_AMD64=//p' "${ROOT}/packaging/install.sh") +[[ "${ver}" =~ ^v[0-9.]+$ && "${sha}" =~ ^[0-9a-f]{64}$ ]] || finish fail "packaging/install.sh pins no K3D_VERSION and K3D_SHA256_AMD64" +K3D=${WORK}/k3d +curl -fsSL --retry 3 -o "${K3D}" "https://github.com/k3d-io/k3d/releases/download/${ver}/k3d-linux-amd64" 2>> "${LOG}" \ + || { K3D=""; finish fail "k3d ${ver} did not download"; } +echo "${sha} ${K3D}" | sha256sum -c - >> "${LOG}" 2>&1 || { K3D=""; finish fail "k3d ${ver}: sha256 mismatch"; } +chmod +x "${K3D}" + +start=${SECONDS} +say "k3d ${ver} cluster create ${CLUSTER} --image ${IMAGE_TAG} --wait ${NODE_ARGS[*]}" +"${K3D}" cluster create "${CLUSTER}" --image "${IMAGE_TAG}" --wait --timeout "${TIMEOUT}s" "${NODE_ARGS[@]}" \ + --kubeconfig-update-default=false >> "${LOG}" 2>&1 \ + || finish fail "k3d cluster create failed: $(tail -3 "${LOG}" | tr '\n' ' ')" +created=$((SECONDS - start)) + +kc() { docker exec "${NODE}" kubectl "$@"; } +# The manifests the Dockerfile bakes, and the DaemonSets they define (namespace/name). +manifests=$(sed -nE 's#^COPY share/[^ ]+ /var/lib/rancher/k3s/server/manifests/([^ ]+)$#\1#p' "${ROOT}/Dockerfile" | sort) +daemonsets=$(sed -nE 's#^COPY (share/[^ ]+) /var/lib/rancher/k3s/server/manifests/[^ ]+$#\1#p' "${ROOT}/Dockerfile" \ + | (cd "${ROOT}" && xargs python3 -c ' +import re, sys +for path in sys.argv[1:]: + for doc in re.split(r"^---\s*$", open(path).read(), flags=re.M): + if re.search(r"^kind: DaemonSet\s*$", doc, re.M): + meta = re.search(r"^metadata:\n((?:[ ]+.*\n)+)", doc, re.M).group(1) + name = re.search(r"^ name: (\S+)", meta, re.M).group(1) + ns = re.search(r"^ namespace: (\S+)", meta, re.M) + print("%s/%s" % (ns.group(1) if ns else "default", name)) +') | sort -u) + +problems="" steady="" steady_since=${SECONDS} +deadline=$((SECONDS + TIMEOUT)) +while :; do + problems="" + ready=$(kc get nodes -o jsonpath='{range .items[*]}{.status.conditions[?(@.type=="Ready")].status}{"\n"}{end}' 2>> "${LOG}") + [ "${ready}" = True ] || problems+="node not Ready (${ready:-no node}); " + have=$(docker exec "${NODE}" ls /var/lib/rancher/k3s/server/manifests 2>> "${LOG}") + for m in ${manifests}; do + grep -qx "${m}" <<< "${have}" || problems+="manifest ${m} not baked; " + done + ds=$(kc get ds -A -o jsonpath='{range .items[*]}{.metadata.namespace}/{.metadata.name}{"\n"}{end}' 2>> "${LOG}") + for d in ${daemonsets}; do + grep -qx "${d}" <<< "${ds}" || problems+="DaemonSet ${d} missing; " + done + # Every pod: Running with every container ready, or Succeeded. One line per pod: + # / =:,... + pods=$(kc get pods -A -o jsonpath='{range .items[*]}{.metadata.namespace}/{.metadata.name} {.status.phase} {range .status.containerStatuses[*]}{.name}={.ready}:{.restartCount},{end}{"\n"}{end}' 2>> "${LOG}") + [ -n "${pods}" ] || problems+="no pods; " + while read -r name phase states; do + [ -n "${name}" ] || continue + case "${phase}" in + Succeeded) continue ;; + Running) [ -n "${states}" ] && [[ "${states}" != *=false:* ]] && continue ;; + esac + problems+="pod ${name} ${phase}${states:+ ${states%,}}; " + done <<< "${pods}" + if [ -z "${problems}" ]; then + # Up: it must now hold, the same pods with the same restart counts, for SETTLE. + [ "${pods}" = "${steady}" ] || { steady=${pods}; steady_since=${SECONDS}; } + [ $((SECONDS - steady_since)) -lt "${SETTLE}" ] || break + [ "${SECONDS}" -lt $((deadline + SETTLE)) ] || break + else + steady="" + [ "${SECONDS}" -lt "${deadline}" ] || break + fi + sleep "${POLL}" +done +if [ -z "${problems}" ] && [ $((SECONDS - steady_since)) -lt "${SETTLE}" ]; then + problems="the pods did not hold ${SETTLE}s without a restart: $(tr '\n' ' ' <<< "${pods}"); " +fi +waited=$((SECONDS - start)) +{ kc get nodes -o wide; kc get pods -A -o wide; kc get ds -A; } >> "${LOG}" 2>&1 +if [ -n "${problems}" ]; then + finish fail "after ${waited}s: ${problems%; }" +fi +finish pass "image built in ${built}s, cluster created in ${created}s, ready in ${waited}s; $(wc -w <<< "${manifests}") baked manifests, $(wc -w <<< "${daemonsets}") DaemonSets, $(grep -c . <<< "${pods}") pods up and steady for ${SETTLE}s" diff --git a/canary/plan.py b/canary/plan.py new file mode 100755 index 00000000..c920aec4 --- /dev/null +++ b/canary/plan.py @@ -0,0 +1,485 @@ +#!/usr/bin/env python3 +"""The canary's engine table (canary/engines.toml): where each engine's image is pinned, +what to check, which engines a change selects, and the merged report. + + plan.py ids the engine ids + plan.py ref ID the image reference ID's locator matches (exactly one) + plan.py show ID the engine as JSON: its table, the resolved pin (ref, + image, tag, digest), the version the tag implies, the + flags canary/flags.sh names for it, its derived + constants and its golden + plan.py shell ID the same as shell assignments, for canary/run.sh + plan.py model-dir ID the model cache directory (CANARY_MODELS, default + ~/.cache/k3d-gpu-canary) + plan.py matrix [--mode auto|all] [--engines CSV] [--base REF | --base-root DIR] + the engines to run and the workflow's job matrices + plan.py size REF the linux/amd64 image's compressed size, in bytes + plan.py report --selected JSON --parts DIR --sha SHA --ref REF --out FILE + canary-result.json from the per-engine results; exits + non-zero unless every selected engine is green + +Every subcommand takes --root DIR (default: this repo). In auto mode an engine is selected +when, between the base tree and this one, its resolved image reference, its table in +engines.toml, its golden, one of its derived constants or one of the flags canary/flags.sh +names for it changed (the node engine: also any file under its paths). A change to the +harness itself (canary/ outside engines.toml and goldens/, the defaults table, or +.github/workflows/canary.yml) selects every engine. --base REF compares with +`git merge-base REF HEAD`; --base-root compares with a directory. + +Python standard library only (tomllib: Python 3.11+). +""" + +import argparse +import hashlib +import json +import os +import re +import shlex +import shutil +import subprocess +import sys +import tempfile +import tomllib + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.dirname(HERE) +TOML = "canary/engines.toml" +WORKFLOW = ".github/workflows/canary.yml" +KINDS = ("runtime", "surface", "node") +VERDICTS = ("pass", "fail", "isa-skip", "too-big", "unparseable") +RESULT_KEYS = ("kind", "pinned_ref", "verdict", "hard", "exact", "cpu_flags", "detail") +# Files every plan reads besides the locators, derived files and node paths the table names. +BASE_PATHS = ("canary", WORKFLOW, "scripts/k3d-gpu", "console/model_opt.py", "console/fit.py", "bench/h54/h54.py") + + +class PlanError(Exception): + pass + + +def load(root): + path = os.path.join(root, TOML) + if not os.path.exists(path): + return {}, {} + with open(path, "rb") as f: + doc = tomllib.load(f) + engines = {} + for e in doc.get("engine", []): + if e.get("id") in engines: + raise PlanError("%s: engine %s is listed twice" % (TOML, e["id"])) + if e.get("kind") not in KINDS: + raise PlanError("%s: engine %s has kind %r, not one of %s" % (TOML, e.get("id"), e.get("kind"), ", ".join(KINDS))) + engines[e["id"]] = e + return doc.get("defaults", {}), engines + + +def read(root, rel): + with open(os.path.join(root, rel), encoding="utf-8") as f: + return f.read() + + +def split_ref(ref): + """name, tag ('' when none) and digest of name[:tag]@sha256:.""" + m = re.fullmatch(r"([^@\s]+)@(sha256:[0-9a-f]{64})", ref) + if not m: + raise PlanError("%r is not name[:tag]@sha256:<64 hex>" % ref) + name, digest = m.groups() + tag = "" + if ":" in name.rsplit("/", 1)[-1]: + name, tag = name.rsplit(":", 1) + return name, tag, digest + + +def resolve(eng, root): + """The engine's pin: {ref, image (its name), tag, digest}, from exactly one locator match.""" + loc = eng["locator"] + try: + text = read(root, loc["file"]) + except FileNotFoundError: + raise PlanError("%s: locator file %s does not exist" % (eng["id"], loc["file"])) + matches = list(re.finditer(loc["regex"], text, re.M)) + if len(matches) != 1: + raise PlanError("%s: locator %r matches %d image references in %s, not exactly one" + % (eng["id"], loc["regex"], len(matches), loc["file"])) + g = matches[0].groupdict() + if g.get("ref"): + name, tag, digest = split_ref(g["ref"]) + else: + name, tag, digest = loc["name"], g.get("tag") or "", g["digest"] + ref = "%s%s@%s" % (name, ":" + tag if tag else "", digest) + return {"ref": ref, "image": name, "tag": tag, "digest": digest} + + +def expected_version(probe, tag): + """The version the pinned tag names, read with version_probe.tag; '' when the pin + has no tag or the probe names no tag regex.""" + rx = (probe or {}).get("tag") + if not rx or not tag: + return "" + m = re.search(rx, tag) + if not m: + raise PlanError("tag %r does not match version_probe.tag %r" % (tag, rx)) + return m.group("v") + + +_flags_cache = {} + + +def flags_lines(root): + if root not in _flags_cache: + out = subprocess.run([os.path.join(HERE, "flags.sh"), "--root", root], capture_output=True, text=True) + if out.returncode != 0: + raise PlanError("canary/flags.sh failed on %s: %s" % (root, out.stderr.strip())) + _flags_cache[root] = [dict(zip(("family", "source", "kind", "name"), ln.split("\t"))) + for ln in out.stdout.splitlines() if ln] + return _flags_cache[root] + + +def flags_for(eng, root): + srcs = set(eng.get("flags_source", [])) + return [{"source": r["source"], "kind": r["kind"], "name": r["name"]} for r in flags_lines(root) + if r["family"] == eng.get("family") and r["source"] in srcs] + + +def derived_for(eng, root): + out = [] + for d in eng.get("derived", []): + try: + text = read(root, d["file"]) + except FileNotFoundError: + raise PlanError("%s: derived file %s does not exist" % (eng["id"], d["file"])) + ms = list(re.finditer(d["regex"], text, re.M)) + if len(ms) != 1: + raise PlanError("%s: derived %r matches %d times in %s, not exactly once" % (eng["id"], d["regex"], len(ms), d["file"])) + out.append({"file": d["file"], "regex": d["regex"], "of": d["of"], "value": ms[0].group("v")}) + return out + + +def model_dir(eng): + base = os.environ.get("CANARY_MODELS") or os.path.join(os.path.expanduser("~"), ".cache", "k3d-gpu-canary") + return os.path.join(base, model_slug(eng)) + + +def model_slug(eng): + return "%s@%s" % (eng["model"].replace("/", "__"), eng["revision"]) + + +def subst(s, eng, name): + mount = eng.get("mount", "").replace("{model}", eng.get("model", "")) + return (s.replace("{mount}", mount).replace("{model}", eng.get("model", "")).replace("{name}", name) + .replace("{threads}", str(os.cpu_count() or 1))) + + +def show(eid, root): + defaults, engines = load(root) + if eid not in engines: + raise PlanError("no engine %r in %s (engines: %s)" % (eid, TOML, ", ".join(sorted(engines)))) + eng = dict(engines[eid]) + doc = {"ready_timeout": defaults.get("ready_timeout", 600), "size_factor": defaults.get("size_factor", 3.5), + "name": defaults.get("name", "canary"), "informational": False} + doc.update(eng) + doc.update(resolve(eng, root)) + doc["pinned_ref"] = doc["ref"] + doc["expected_version"] = expected_version(eng.get("version_probe"), doc["tag"]) + if eng["kind"] != "node": + doc["flags"] = flags_for(eng, root) + doc["derived"] = derived_for(eng, root) + if eng["kind"] == "runtime": + doc["model_dir"] = model_dir(eng) + doc["golden_doc"] = json.loads(read(root, eng["golden"])) + name = doc["name"] + for k in ("args", "env", "prepare", "prepare_env"): + doc[k] = [subst(a, eng, name) for a in eng.get(k, [])] + doc["mount"] = subst(eng.get("mount", "/models"), eng, name) + return doc + + +def shell(doc): + def one(k, v): + return "%s=%s" % (k, shlex.quote(str(v))) + + def arr(k, vs): + return "%s=(%s)" % (k, " ".join(shlex.quote(str(v)) for v in vs)) + probe = doc.get("version_probe") or {} + lines = [one("E_ID", doc["id"]), one("E_KIND", doc["kind"]), one("E_INFORMATIONAL", "1" if doc["informational"] else ""), + one("E_REF", doc["ref"]), one("E_TAG", doc["tag"]), one("E_SIZE_FACTOR", doc["size_factor"]), + one("E_READY_TIMEOUT", doc["ready_timeout"]), one("E_SURFACE", (doc.get("surface") or {}).get("driver", "")), + one("E_ISA_SIGNAL", doc.get("isa_signal", "")), one("E_EXPECT_VERSION", doc["expected_version"])] + if doc["kind"] == "runtime": + lines += [one("E_MODEL", doc["model"]), one("E_REVISION", doc["revision"]), one("E_MODEL_DIR", doc["model_dir"]), + arr("E_FILES", doc["files"]), one("E_MOUNT", doc["mount"]), one("E_WRITABLE", doc.get("writable", "")), + arr("E_ARGS", doc["args"]), arr("E_ENV", doc["env"]), arr("E_PREPARE", doc["prepare"]), + arr("E_PREPARE_ENV", doc["prepare_env"]), arr("E_CAP_ADD", doc.get("cap_add", [])), + one("E_SHM_SIZE", doc.get("shm_size", "")), + one("E_API", doc["api"]), one("E_PORT", doc["port"]), one("E_NAME", doc["name"]), + one("E_GOLDEN", doc["golden"]), one("E_VERSION_PATH", probe.get("path", "")), + one("E_VERSION_KEY", probe.get("key", "")), one("E_VERSION_REGEX", probe.get("regex", ""))] + return "\n".join(lines) + + +# ---- planning ---------------------------------------------------------------------- + +def digest_paths(root, paths): + h = hashlib.sha256() + for p in paths: + full = os.path.join(root, p) + files = [] + if os.path.isdir(full): + for d, _, fs in os.walk(full): + files += [os.path.join(d, f) for f in fs] + elif os.path.exists(full): + files = [full] + for f in sorted(files): + h.update(os.path.relpath(f, root).encode() + b"\0") + with open(f, "rb") as fh: + h.update(fh.read()) + return h.hexdigest() + + +def fingerprint(eid, root): + """What an engine's canary depends on in a tree; an error is part of it (a broken + locator in the head tree is a change that must run, and fail).""" + defaults, engines = load(root) + eng = engines.get(eid) + if eng is None: + return None + fp = {"table": json.dumps(eng, sort_keys=True)} + for key, fn in (("pin", lambda: resolve(eng, root)["ref"]), + ("derived", lambda: [d["value"] for d in derived_for(eng, root)]), + ("flags", lambda: sorted(json.dumps(f, sort_keys=True) for f in flags_for(eng, root)) + if eng["kind"] != "node" else []), + ("golden", lambda: read(root, eng["golden"]) if eng.get("golden") else ""), + ("paths", lambda: digest_paths(root, eng.get("paths", [])))): + try: + fp[key] = fn() + except (PlanError, OSError) as e: + fp[key] = "error: %s" % e + return fp + + +def harness(root): + """The canary's own code and settings: a change here runs every engine.""" + h = hashlib.sha256() + canary = os.path.join(root, "canary") + for d, _, fs in sorted(os.walk(canary)) if os.path.isdir(canary) else []: + rel = os.path.relpath(d, root) + if rel == "canary/goldens" or rel.startswith("canary/goldens/") or "__pycache__" in rel: + continue + for f in sorted(fs): + if (rel == "canary" and f == "engines.toml") or f.endswith(".md"): + continue + h.update(os.path.join(rel, f).encode() + b"\0") + with open(os.path.join(d, f), "rb") as fh: + h.update(fh.read()) + defaults, _ = load(root) + h.update(json.dumps(defaults, sort_keys=True).encode()) + wf = os.path.join(root, WORKFLOW) + if os.path.exists(wf): + with open(wf, "rb") as fh: + h.update(fh.read()) + return h.hexdigest() + + +def git(root, *args): + out = subprocess.run(["git", "-C", root, *args], capture_output=True, text=True) + if out.returncode != 0: + raise PlanError("git %s: %s" % (" ".join(args), out.stderr.strip())) + return out.stdout.strip() + + +def base_tree(root, ref, dest): + """The files a plan reads, from `git merge-base REF HEAD`, written under dest.""" + commit = git(root, "merge-base", ref, "HEAD") + _, engines = load(root) + paths = set(BASE_PATHS) + for e in engines.values(): + paths.add(e["locator"]["file"]) + paths.update(d["file"] for d in e.get("derived", [])) + paths.update(p.rstrip("/") for p in e.get("paths", [])) + present = [p for p in sorted(paths) + if subprocess.run(["git", "-C", root, "cat-file", "-e", "%s:%s" % (commit, p)], capture_output=True).returncode == 0] + if present: + arc = subprocess.run(["git", "-C", root, "archive", "--format=tar", commit, "--", *present], capture_output=True, check=True) + subprocess.run(["tar", "-x", "-C", dest], input=arc.stdout, check=True) + return commit + + +def matrix(root, mode, engines_csv, base=None, base_root=None): + defaults, engines = load(root) + ids = list(engines) + if engines_csv and engines_csv != "auto": + want = [e.strip() for e in engines_csv.split(",") if e.strip()] + unknown = [e for e in want if e not in engines] + if unknown: + raise PlanError("unknown engine id(s): %s (engines: %s)" % (", ".join(unknown), ", ".join(ids))) + chosen = [e for e in ids if e in want] + elif mode == "all": + chosen = ids + else: + tmp = None + try: + if base_root is None: + tmp = tempfile.mkdtemp(prefix="canary-base.") + base_tree(root, base or "origin/main", tmp) + base_root = tmp + if harness(base_root) != harness(root): + chosen = ids + else: + chosen = [e for e in ids if fingerprint(e, base_root) != fingerprint(e, root)] + finally: + if tmp: + shutil.rmtree(tmp, ignore_errors=True) + selected, runtime, surface, node = [], [], [], False + for eid in chosen: + e = engines[eid] + try: + ref = resolve(e, root)["ref"] + except PlanError: + ref = "" + selected.append({"id": eid, "kind": e["kind"], "informational": bool(e.get("informational")), "pinned_ref": ref}) + if e["kind"] == "runtime": + slug = model_slug(e) + runtime.append({"id": eid, "model": e["model"], "revision": e["revision"], + "cache_key": "canary-model-" + slug, "cache_path": "~/.cache/k3d-gpu-canary/" + slug}) + elif e["kind"] == "surface": + surface.append({"id": eid}) + else: + node = True + return {"selected": selected, "runtime": runtime, "surface": surface, "node": node} + + +# ---- images -------------------------------------------------------------------------- + +def image_bytes(ref): + """Compressed bytes of the linux/amd64 image (layers and config), from the registry.""" + def raw(r): + out = subprocess.run(["docker", "buildx", "imagetools", "inspect", "--raw", r], capture_output=True, text=True) + if out.returncode != 0: + raise PlanError("cannot read the manifest of %s: %s" % (r, out.stderr.strip())) + return json.loads(out.stdout) + m = raw(ref) + if "manifests" in m: + amd = [x for x in m["manifests"] if (x.get("platform") or {}).get("os") == "linux" + and (x.get("platform") or {}).get("architecture") == "amd64"] + if not amd: + raise PlanError("%s has no linux/amd64 image" % ref) + m = raw("%s@%s" % (ref.split("@")[0], amd[0]["digest"])) + return sum(int(x.get("size", 0)) for x in m.get("layers", [])) + int((m.get("config") or {}).get("size", 0)) + + +# ---- report -------------------------------------------------------------------------- + +def report(selected, parts, sha, ref, out, summary=None, github_output=None): + engines, red, isa = {}, [], [] + for s in selected: + p = os.path.join(parts, s["id"] + ".json") + r = None + try: + with open(p, encoding="utf-8") as f: + r = json.load(f) + if not all(k in r for k in RESULT_KEYS) or r["verdict"] not in VERDICTS: + r = dict(r, verdict="fail", hard=False, exact=False, + detail="malformed result (%s): %s" % (p, json.dumps(r)[:200])) + except (OSError, ValueError): + r = {"kind": s.get("kind", "runtime"), "pinned_ref": s.get("pinned_ref", ""), "verdict": "fail", "hard": False, + "exact": False, "cpu_flags": [], "detail": "no result: the job failed or was cancelled before writing one"} + r = {k: r.get(k) for k in RESULT_KEYS} + r["kind"] = r["kind"] if r["kind"] in KINDS else s.get("kind", "runtime") + r["pinned_ref"] = str(r["pinned_ref"] or s.get("pinned_ref", "")) + r["hard"], r["exact"] = bool(r["hard"]), bool(r["exact"]) + r["cpu_flags"] = list(r["cpu_flags"] or []) + r["detail"] = str(r["detail"] or "") + engines[s["id"]] = r + green = r["verdict"] == "pass" and r["hard"] + if not green and not s.get("informational"): + red.append(s["id"]) + if r["verdict"] == "isa-skip" and not s.get("informational"): + isa.append(s["id"]) # the workflow re-dispatches these, hoping for another CPU + with open(out, "w", encoding="utf-8") as f: + json.dump({"sha": sha, "ref": ref, "engines": engines}, f, indent=1, sort_keys=True) + f.write("\n") + rows = ["| engine | kind | verdict | hard | exact | pinned | detail |", "|---|---|---|---|---|---|---|"] + info = {s["id"] for s in selected if s.get("informational")} + for eid, r in engines.items(): + rows.append("| %s%s | %s | %s | %s | %s | `%s` | %s |" % ( + eid, " (informational)" if eid in info else "", r["kind"], r["verdict"], r["hard"], r["exact"], + r["pinned_ref"].split("@")[0], r["detail"].replace("|", "/").replace("\n", " ")[:300])) + text = "## Canary %s\n\n%s\n\n%s\n" % ( + "green" if not red else "red: " + ", ".join(red), + "\n".join(rows) if engines else "No engine selected: nothing an engine depends on changed.", + "Green means verdict pass with hard true. exact false (a golden mismatch) stays green but is not auto-merge evidence.") + print(text) + if summary: + with open(summary, "a", encoding="utf-8") as f: + f.write(text) + if github_output: + with open(github_output, "a", encoding="utf-8") as f: + f.write("isa_skip=%s\ngreen=%s\n" % (",".join(isa), "false" if red else "true")) + return 1 if red else 0 + + +def main(argv=None): + p = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + p.add_argument("--root", default=REPO) + sub = p.add_subparsers(dest="cmd", required=True) + sub.add_parser("ids") + for c in ("ref", "show", "shell", "model-dir"): + s = sub.add_parser(c) + s.add_argument("id") + s.add_argument("--root", default=argparse.SUPPRESS) + m = sub.add_parser("matrix") + m.add_argument("--mode", choices=("auto", "all"), default="auto") + m.add_argument("--engines", default="auto") + m.add_argument("--base") + m.add_argument("--base-root") + m.add_argument("--root", default=argparse.SUPPRESS) + m.add_argument("--github-output") + s = sub.add_parser("size") + s.add_argument("ref") + r = sub.add_parser("report") + r.add_argument("--selected", required=True, help="JSON list, or @FILE") + r.add_argument("--parts", required=True) + r.add_argument("--sha", required=True) + r.add_argument("--ref", required=True) + r.add_argument("--out", required=True) + r.add_argument("--summary") + r.add_argument("--github-output") + a = p.parse_args(argv) + root = os.path.abspath(a.root) + try: + if a.cmd == "ids": + print("\n".join(load(root)[1])) + elif a.cmd == "ref": + _, engines = load(root) + if a.id not in engines: + raise PlanError("no engine %r" % a.id) + print(resolve(engines[a.id], root)["ref"]) + elif a.cmd == "show": + print(json.dumps(show(a.id, root), indent=1, sort_keys=True)) + elif a.cmd == "shell": + print(shell(show(a.id, root))) + elif a.cmd == "model-dir": + print(model_dir(load(root)[1][a.id])) + elif a.cmd == "matrix": + doc = matrix(root, a.mode, a.engines, a.base, os.path.abspath(a.base_root) if a.base_root else None) + print(json.dumps(doc, indent=1)) + if a.github_output: + with open(a.github_output, "a", encoding="utf-8") as f: + for k in ("runtime", "surface", "selected"): + f.write("%s=%s\n" % (k, json.dumps(doc[k], separators=(",", ":")))) + f.write("node=%s\n" % ("true" if doc["node"] else "false")) + elif a.cmd == "size": + print(image_bytes(a.ref)) + elif a.cmd == "report": + sel = a.selected + if sel.startswith("@"): + with open(sel[1:], encoding="utf-8") as f: + sel = f.read() + return report(json.loads(sel), a.parts, a.sha, a.ref, a.out, a.summary, a.github_output) + except PlanError as e: + print("plan.py: %s" % e, file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/canary/probe.py b/canary/probe.py new file mode 100755 index 00000000..15389f08 --- /dev/null +++ b/canary/probe.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""The canary's runtime checks against a serving engine (canary/run.sh starts it). + + probe.py --base URL --api /v1 --name NAME --golden FILE --timeout S --out FILE + [--container NAME] [--version-path P --version-key K --version-regex R] + [--expect-version V] + +1. Ready: GET {api}/models until it lists NAME, for up to --timeout seconds; with + --container, stop early once `docker inspect` says the container exited. +2. One completion: POST {api}/completions, the golden's prompt, temperature 0, top_k 1, + seed 0, max_tokens 8. Hard: HTTP 200, finish_reason stop or length, + usage.completion_tokens > 0 and the text matches the golden's regex. +3. One streamed completion: at least 2 chunks with choices, then data: [DONE] last. +4. The version the server reports (GET --version-path, JSON key --version-key, read with + --version-regex) equals --expect-version, when both are known. +5. Exact: the text equals the golden's exact text (null: none recorded). A mismatch keeps + hard and clears exact. + +Writes {ready, ready_s, exited, exit_code, hard, exact, text, version, stream_chunks, +failures, notes} to --out. Python standard library only. +""" + +import argparse +import json +import re +import subprocess +import time +import urllib.error +import urllib.request + +MAX_TOKENS = 8 + + +def http(url, body=None, timeout=60): + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"} if data else {}) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + return r.status, r.read() + except urllib.error.HTTPError as e: + return e.code, e.read() + + +def container_state(name): + """(running, exit code) of a container, (True, None) when docker cannot tell.""" + try: + out = subprocess.run(["docker", "inspect", "-f", "{{.State.Running}} {{.State.ExitCode}}", name], + capture_output=True, text=True, timeout=30).stdout.split() + return out[0] == "true", int(out[1]) + except (OSError, ValueError, IndexError, subprocess.TimeoutExpired): + return True, None + + +def wait_ready(a, res): + start = time.monotonic() + while True: + try: + code, body = http(a.base + a.api + "/models", timeout=10) + if code == 200 and a.name in [m.get("id") for m in json.loads(body).get("data", [])]: + res["ready"], res["ready_s"] = True, round(time.monotonic() - start, 1) + return True + except (OSError, ValueError, AttributeError): + pass + if a.container: + running, rc = container_state(a.container) + if not running: + res["exited"], res["exit_code"] = True, rc + res["failures"].append("the engine exited (code %s) before it listed %s" % (rc, a.name)) + return False + if time.monotonic() - start >= a.timeout: + res["failures"].append("never ready: %s%s/models did not list %s within %ds" % (a.base, a.api, a.name, a.timeout)) + return False + time.sleep(min(2.0, max(0.2, a.timeout / 20))) + + +def request(a, golden, stream): + body = {"model": a.name, "prompt": golden["prompt"], "max_tokens": MAX_TOKENS, "temperature": 0, "top_k": 1, "seed": 0} + if stream: + body["stream"] = True + return body + + +def complete(a, golden, res): + code, raw = http(a.base + a.api + "/completions", request(a, golden, False), timeout=300) + if code != 200: + res["failures"].append("completion: HTTP %d %s" % (code, raw[:200].decode(errors="replace"))) + return + try: + doc = json.loads(raw) + choice = doc["choices"][0] + text = choice.get("text") or "" + fin = choice.get("finish_reason") + tokens = (doc.get("usage") or {}).get("completion_tokens") or 0 + except (ValueError, KeyError, IndexError, TypeError) as e: + res["failures"].append("completion: unreadable body (%s): %s" % (e, raw[:200].decode(errors="replace"))) + return + res["text"] = text + if fin not in ("stop", "length"): + res["failures"].append("completion: finish_reason %r, not stop or length" % fin) + if tokens <= 0: + res["failures"].append("completion: usage.completion_tokens is %r" % tokens) + if not re.search(golden["regex"], text): + res["failures"].append("completion: text %r does not match /%s/" % (text, golden["regex"])) + + +def stream(a, golden, res): + req = urllib.request.Request(a.base + a.api + "/completions", data=json.dumps(request(a, golden, True)).encode(), + headers={"Content-Type": "application/json"}) + events = [] + try: + with urllib.request.urlopen(req, timeout=300) as r: + for line in r: + line = line.decode(errors="replace").strip() + if line.startswith("data:"): + events.append(line[5:].strip()) + except urllib.error.HTTPError as e: + res["failures"].append("stream: HTTP %d" % e.code) + return + except OSError as e: + res["failures"].append("stream: %s" % e) + return + chunks = 0 + for ev in events: + if ev == "[DONE]": + continue + try: + doc = json.loads(ev) + except ValueError: + res["failures"].append("stream: a chunk is not JSON: %r" % ev[:120]) + return + if "error" in doc: + res["failures"].append("stream: error chunk %s" % json.dumps(doc["error"])[:200]) + return + if doc.get("choices"): + chunks += 1 + res["stream_chunks"] = chunks + if chunks < 2: + res["failures"].append("stream: %d chunks with choices, want at least 2" % chunks) + if not events or events[-1] != "[DONE]": + res["failures"].append("stream: did not end with data: [DONE]") + + +def version(a, res): + if not a.version_path: + return + code, raw = http(a.base + a.version_path, timeout=30) + try: + v = str(json.loads(raw)[a.version_key]) + except (ValueError, KeyError, TypeError): + res["failures"].append("version: GET %s answered %d without %s" % (a.version_path, code, a.version_key)) + return + m = re.search(a.version_regex, v) if a.version_regex else None + res["version"] = m.group("v") if m else v + if not a.expect_version: + res["notes"].append("server version %s (the pin has no tag to compare)" % v) + elif res["version"] != a.expect_version: + res["failures"].append("version: the server reports %s, the pinned tag says %s" % (v, a.expect_version)) + + +def main(): + p = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + p.add_argument("--base", required=True) + p.add_argument("--api", required=True) + p.add_argument("--name", required=True) + p.add_argument("--golden", required=True) + p.add_argument("--timeout", type=float, required=True) + p.add_argument("--out", required=True) + p.add_argument("--container") + p.add_argument("--version-path", default="") + p.add_argument("--version-key", default="version") + p.add_argument("--version-regex", default="") + p.add_argument("--expect-version", default="") + a = p.parse_args() + with open(a.golden, encoding="utf-8") as f: + golden = json.load(f) + res = {"ready": False, "ready_s": None, "exited": False, "exit_code": None, "hard": False, "exact": False, + "text": None, "version": None, "stream_chunks": 0, "failures": [], "notes": []} + if wait_ready(a, res): + complete(a, golden, res) + stream(a, golden, res) + version(a, res) + res["hard"] = not res["failures"] + res["exact"] = res["hard"] and golden.get("exact") is not None and res["text"] == golden["exact"] + if res["hard"] and golden.get("exact") is None: + res["notes"].append("no exact golden recorded") + elif res["hard"] and not res["exact"]: + res["notes"].append("exact: %r is not the golden %r" % (res["text"], golden["exact"])) + with open(a.out, "w", encoding="utf-8") as f: + json.dump(res, f, indent=1) + + +if __name__ == "__main__": + main() diff --git a/canary/run.sh b/canary/run.sh new file mode 100755 index 00000000..dfa13665 --- /dev/null +++ b/canary/run.sh @@ -0,0 +1,225 @@ +#!/usr/bin/env bash +# One engine's canary on this machine: a GitHub-hosted runner in .github/workflows/canary.yml. +# +# canary/run.sh ID a runtime engine: its surface check, then serve and probe it +# canary/run.sh --surface ID a surface engine: the surface check only +# +# In order: (1) free disk, when the image needs more than is free and CANARY_FREE_DISK=1 +# (it deletes the runner's preinstalled toolchains: never set it on a workstation); +# (2) record the CPU's avx2, avx512f and amx_tile flags; (3) the model at its pinned +# revision, from the cache (actions/cache restores it) or downloaded; (4) the image's +# size against the free disk (too-big), then docker pull, 3 attempts with backoff; +# (5) the surface check (canary/surface.py); (6) start the engine as the launcher would +# (canary/engines.toml) and probe it (canary/probe.py): ready within the timeout, one +# completion, one stream, its version; (7) an engine that cannot run on this ISA +# (isa_signal, no avx512f) is isa-skip, not a failure. +# +# Writes ${CANARY_OUT_DIR:-./canary-out}/.json (the canary-result.json entry: kind, +# pinned_ref, verdict, hard, exact, cpu_flags, detail) and .log (the engine's log), +# and exits non-zero unless the verdict is pass (an informational engine exits 0). +# +# Environment: CANARY_MODELS (model cache, default ~/.cache/k3d-gpu-canary), +# CANARY_READY_TIMEOUT (seconds, default the engine's), CANARY_PULL_BACKOFF (seconds, +# default 20), CANARY_CPUINFO (default /proc/cpuinfo), CANARY_FREE_BYTES (free disk, +# default df of docker's root), HF_ENDPOINT (default https://huggingface.co). + +set -uo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +OUT_DIR=${CANARY_OUT_DIR:-${PWD}/canary-out} +surface_only="" +if [ "${1:-}" = --surface ]; then surface_only=1; shift; fi +ID=${1:?usage: canary/run.sh [--surface] ID} +mkdir -p "${OUT_DIR}" +RESULT=${OUT_DIR}/${ID}.json +LOG=${OUT_DIR}/${ID}.log +WORK=$(mktemp -d "${TMPDIR:-/tmp}/canary-${ID}.XXXXXX") +NAME=canary-${ID} +: > "${LOG}" +E_KIND=${surface_only:+surface} E_REF="" E_INFORMATIONAL="" E_SURFACE="" +cleanup() { + docker rm -f "${NAME}" > /dev/null 2>&1 + # an engine writes as its own user into the copied model tree + rm -rf "${WORK}" 2>/dev/null || docker run --rm -v "${WORK}:/w" --entrypoint rm "${E_REF}" -rf /w/models > /dev/null 2>&1 + rm -rf "${WORK}" 2>/dev/null + return 0 +} +trap cleanup EXIT + +say() { echo "canary ${ID}: $*" >&2; } + +# finish : write the result and exit. A pass needs the surface check +# (when there was one) and the probe (for a runtime engine) to pass. +finish() { + R_VERDICT=$1 R_DETAIL=$2 python3 - "${RESULT}" "${WORK}" <<'PY' +import json, os, sys +out, work = sys.argv[1:] +env = os.environ +verdict, detail = env["R_VERDICT"], [env["R_DETAIL"]] if env["R_DETAIL"] else [] +hard = exact = False +def load(name): + try: + with open(os.path.join(work, name)) as f: + return json.load(f) + except (OSError, ValueError): + return None +surface, probe = load("surface.json"), load("probe.json") +if surface: + detail.append(surface["detail"]) +if probe: + detail += probe["failures"] + probe["notes"] + if probe["text"] is not None: + detail.append("text %r" % probe["text"]) + if probe["ready_s"] is not None: + detail.append("ready after %ss, %d stream chunks%s" % (probe["ready_s"], probe["stream_chunks"], + ", version %s" % probe["version"] if probe["version"] else "")) +if verdict == "pass": + if surface and surface["verdict"] != "pass": + verdict = surface["verdict"] + elif env.get("R_SERVED") and probe is None: + verdict = "fail" + detail.append("canary/probe.py wrote no result") + elif probe is not None and not probe["hard"]: + verdict = "fail" + else: + hard = True + exact = bool(probe["exact"]) if probe is not None else True +res = {"kind": env["E_KIND"] or "runtime", "pinned_ref": env["E_REF"], "verdict": verdict, "hard": hard, "exact": exact, + "cpu_flags": env["R_CPU"].split(), "detail": "; ".join(d for d in detail if d)} +with open(out, "w") as f: + json.dump(res, f, indent=1) + f.write("\n") +print("canary %s: %s (hard %s, exact %s): %s" % (os.path.basename(out)[:-5], verdict, hard, exact, res["detail"])) +sys.exit(0 if verdict == "pass" else 1) +PY + local rc=$? + [ -z "${E_INFORMATIONAL}" ] || rc=0 + exit "${rc}" +} + +# (2) CPU flags +R_CPU="" +cpuflags=" $(grep -m1 '^flags' "${CANARY_CPUINFO:-/proc/cpuinfo}" | cut -d: -f2) " +for f in avx2 avx512f amx_tile; do + [[ "${cpuflags}" == *" ${f} "* ]] && R_CPU+="${f} " +done +export R_CPU + +# The engine, from canary/engines.toml and the tree. +if ! cfg=$(python3 "${HERE}/plan.py" shell "${ID}" 2> "${WORK}/plan.err"); then + export E_KIND E_REF + finish fail "plan: $(tr '\n' ' ' < "${WORK}/plan.err")" +fi +eval "${cfg}" +export E_KIND E_REF +if [ -z "${surface_only}" ] && [ "${E_KIND}" != runtime ]; then finish fail "${ID} is a ${E_KIND} engine: canary/run.sh --surface ${ID}"; fi +cpu_model=$(sed -n 's/^model name[[:space:]]*: //p' "${CANARY_CPUINFO:-/proc/cpuinfo}" | head -1) +say "${E_KIND} ${E_REF}; cpu: ${cpu_model:-unknown}, $(grep -c '^processor' "${CANARY_CPUINFO:-/proc/cpuinfo}") CPUs, flags ${R_CPU:-none}" + +# (3) the model at its pinned revision +if [ -z "${surface_only}" ]; then + if [ "$(cat "${E_MODEL_DIR}/.complete" 2>/dev/null)" = "${E_REVISION}" ]; then + say "model ${E_MODEL}@${E_REVISION} from the cache" + else + hf=${HF_ENDPOINT:-https://huggingface.co} + mkdir -p "${E_MODEL_DIR}" + for f in "${E_FILES[@]}"; do + say "download ${E_MODEL}@${E_REVISION} ${f}" + curl -fsSL --retry 3 --retry-all-errors --retry-delay 5 -o "${E_MODEL_DIR}/${f}.partial" \ + "${hf}/${E_MODEL}/resolve/${E_REVISION}/${f}" 2>> "${LOG}" \ + || finish fail "model: ${hf}/${E_MODEL}/resolve/${E_REVISION}/${f} did not download" + mv -f "${E_MODEL_DIR}/${f}.partial" "${E_MODEL_DIR}/${f}" + done + echo "${E_REVISION}" > "${E_MODEL_DIR}/.complete" + fi +fi + +# (1, 4) size, free disk, pull +free_bytes() { + local root + if [ -n "${CANARY_FREE_BYTES:-}" ]; then echo "${CANARY_FREE_BYTES}"; return; fi + root=$(docker info -f '{{.DockerRootDir}}' 2>/dev/null) || root=/var/lib/docker + df -P -B1 "${root}" 2>/dev/null | awk 'NR == 2 {print $4}' +} +free_disk() { + say "freeing disk: the runner's preinstalled toolchains" + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/.ghcup /opt/hostedtoolcache/CodeQL \ + /usr/local/share/boost /usr/share/swift /usr/local/share/powershell /opt/microsoft 2>> "${LOG}" + sudo docker image prune -af > /dev/null 2>&1 + return 0 +} +if bytes=$(python3 "${HERE}/plan.py" size "${E_REF}" 2>> "${LOG}"); then + need=$(python3 -c 'import sys; print(int(float(sys.argv[1]) * float(sys.argv[2])))' "${bytes}" "${E_SIZE_FACTOR}") + free=$(free_bytes) + if [ "${need}" -gt "${free:-0}" ] && [ "${CANARY_FREE_DISK:-0}" = 1 ]; then + free_disk + free=$(free_bytes) + fi + say "image ${bytes} bytes compressed, ~${need} on disk, ${free} free" + if [ "${need}" -gt "${free:-0}" ]; then + finish too-big "the image is ${bytes} bytes compressed, ~${need} bytes pulled (x${E_SIZE_FACTOR}), ${free} bytes free" + fi +else + say "the image's size is unreadable (see the log); pulling anyway" +fi +pulled="" +for attempt in 1 2 3; do + if docker pull "${E_REF}" > "${WORK}/pull.out" 2>&1; then pulled=1; break; fi + say "pull attempt ${attempt} failed: $(tail -1 "${WORK}/pull.out")" + cat "${WORK}/pull.out" >> "${LOG}" + [ "${attempt}" -eq 3 ] || sleep $(( ${CANARY_PULL_BACKOFF:-20} * attempt )) +done +[ -n "${pulled}" ] || finish fail "pull failed after 3 attempts: $(tail -1 "${WORK}/pull.out")" + +# (5) the surface check +if [ -n "${E_SURFACE}" ]; then + if ! python3 "${HERE}/plan.py" show "${ID}" > "${WORK}/engine.json" \ + || ! python3 "${HERE}/surface.py" check --engine "${WORK}/engine.json" --out "${WORK}/surface.json" >&2; then + finish fail "surface: canary/surface.py did not run" + fi +fi +[ -z "${surface_only}" ] || finish pass "" + +# (6) serve and probe: from here a pass needs the probe's result +export R_SERVED=1 +vols=() envs=() caps=() +if [ -n "${E_WRITABLE}" ]; then + # The engine writes next to the model (OVMS: graph.pbtxt): a copy, never the cache. + mkdir -p "${WORK}/models${E_MOUNT#"${E_WRITABLE}"}" + cp -r "${E_MODEL_DIR}/." "${WORK}/models${E_MOUNT#"${E_WRITABLE}"}/" + rm -f "${WORK}/models${E_MOUNT#"${E_WRITABLE}"}/.complete" + chmod -R a+rwX "${WORK}/models" + vols=(-v "${WORK}/models:${E_WRITABLE}") +else + vols=(-v "${E_MODEL_DIR}:${E_MOUNT}:ro") +fi +for e in ${E_ENV[@]+"${E_ENV[@]}"}; do envs+=(-e "${e}"); done +for c in ${E_CAP_ADD[@]+"${E_CAP_ADD[@]}"}; do caps+=(--cap-add "${c}"); done +[ -z "${E_SHM_SIZE}" ] || caps+=(--shm-size "${E_SHM_SIZE}") +if [ "${#E_PREPARE[@]}" -gt 0 ]; then + penv=() + for e in ${E_PREPARE_ENV[@]+"${E_PREPARE_ENV[@]}"}; do penv+=(-e "${e}"); done + say "prepare: ${E_PREPARE[*]}" + docker run --rm --name "${NAME}-prepare" "${vols[@]}" ${penv[@]+"${penv[@]}"} "${E_REF}" "${E_PREPARE[@]}" >> "${LOG}" 2>&1 \ + || finish fail "the prepare run (${E_PREPARE[*]}) failed: $(tail -3 "${LOG}" | tr '\n' ' ')" +fi +port=$(python3 -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1])') +docker rm -f "${NAME}" > /dev/null 2>&1 +say "serve: ${E_ARGS[*]}" +docker run -d --name "${NAME}" -p "127.0.0.1:${port}:${E_PORT}" "${vols[@]}" ${envs[@]+"${envs[@]}"} \ + ${caps[@]+"${caps[@]}"} "${E_REF}" "${E_ARGS[@]}" > /dev/null 2>> "${LOG}" \ + || finish fail "docker run failed: $(tail -3 "${LOG}" | tr '\n' ' ')" +python3 "${HERE}/probe.py" --base "http://127.0.0.1:${port}" --api "${E_API}" --name "${E_NAME}" \ + --golden "${HERE}/../${E_GOLDEN}" --timeout "${CANARY_READY_TIMEOUT:-${E_READY_TIMEOUT}}" --container "${NAME}" \ + --version-path "${E_VERSION_PATH}" --version-key "${E_VERSION_KEY}" --version-regex "${E_VERSION_REGEX}" \ + --expect-version "${E_EXPECT_VERSION}" --out "${WORK}/probe.json" +docker logs "${NAME}" >> "${LOG}" 2>&1 +docker rm -f "${NAME}" > /dev/null 2>&1 + +# (7) an engine that cannot run on this ISA +ready=$(python3 -c 'import json, sys; d = json.load(open(sys.argv[1])); print("%s %s" % (d["ready"], d["exit_code"]))' "${WORK}/probe.json") +if [ "${ready%% *}" = False ] && [ -n "${E_ISA_SIGNAL}" ] && [[ " ${R_CPU}" != *" avx512f "* ]] \ + && { [ "${ready#* }" = 132 ] || grep -Eq -- "${E_ISA_SIGNAL}" "${LOG}"; }; then + finish isa-skip "this runner has no avx512f and the engine stopped on its ISA (exit ${ready#* }; /${E_ISA_SIGNAL}/ in its log)" +fi +finish pass "" diff --git a/canary/surface.py b/canary/surface.py new file mode 100755 index 00000000..c529bb9a --- /dev/null +++ b/canary/surface.py @@ -0,0 +1,495 @@ +#!/usr/bin/env python3 +"""The canary's surface check: every flag and env name k3d-gpu emits for an engine +(canary/flags.sh) exists in the pinned image, and the constants k3d-gpu derives from the +image (console/plan.py's vLLM versions, the NPU driver and firmware facts) still match it. +Runs with no device: nothing here needs a GPU, an NPU or a Gaudi. + + surface.py check --engine FILE --out FILE [--canary DIR] + host side: FILE is `canary/plan.py show `; runs the image with docker and + writes {verdict: pass|fail|unparseable, detail, method, version, missing} + surface.py inside --pkg NAME [--root DIR] + inside an image (docker run --entrypoint python3 -v canary:/canary:ro ...): finds the + installed package on sys.path without importing it and prints what its sources + define, as JSON {root, version, flags, envs}; exit 3 with {error} when unparseable + +Drivers (engines.toml surface.driver): + vllm `vllm serve --help=all` gives the flags when it runs on the CPU; otherwise + the static parse of vllm/engine/arg_utils.py (add_argument literals) and + every vllm/entrypoints/**/cli_args.py (add_argument literals and the + fields of its *Args dataclasses, which become --kebab-case flags), where + --no-X counts as present when --X is (BooleanOptionalAction). Env names + are always the keys of vllm/envs.py's environment_variables dict; the + version is the package's dist-info. Underscores and dashes are one flag + (vLLM's FlexibleArgumentParser). + llama-server llama-server --help + llama-convert convert_hf_to_gguf.py --help, and llama-quantize must answer --help + ovms ovms --help; with `deb`, that package's version (dpkg-query); with + `firmware`, the build date inside that firmware file + gst gst-inspect-1.0 per element: it must exist and have each property; + elements named in `device_only` (prefixes) register only on a device + sglang python3 -m sglang.launch_server --help, and the package version; a long + flag may be argparse's abbreviation of exactly one option (ABBREV_DRIVERS) +A help or parse that fails, or yields no flags, is unparseable: red, never skipped. + +Python 3.8+ standard library only (the `inside` mode runs on the image's Python). +""" + +import argparse +import ast +import glob +import json +import os +import re +import subprocess +import sys +import tempfile + +FLAG = re.compile(r"-{1,2}[A-Za-z][A-Za-z0-9_.-]*") +HELP_MIN = 3 # fewer flags than this in a help text is no help text + + +class Unparseable(Exception): + pass + + +# ---- help output --------------------------------------------------------------------- + +def help_flags(text): + """Flags an option listing defines: lines that start (after indentation) with an + option, as argparse, cxxopts and llama.cpp print them ("-a, --bb B", "--c, --no-c", + "-ngl, --gpu-layers, --n-gpu-layers N text"). Usage lines and prose are skipped.""" + flags = set() + for line in text.splitlines(): + s = line.strip() + # options sit at most 8 columns in; deeper lines continue a description + if not s.startswith("-") or s.startswith("---") or len(line) - len(line.lstrip()) > 8: + continue + # the option column ends where the description starts (two spaces after a word) + col = re.split(r"(?<=[^,\s])\s{2,}", s, maxsplit=1)[0] + for item in col.split(","): + word = item.strip().split(" ", 1)[0].split("=", 1)[0] + if not word: + continue + if not FLAG.fullmatch(word): + break + flags.add(word) + if len(flags) < HELP_MIN: + raise Unparseable("no option listing in the help output (%d flags)" % len(flags)) + return flags + + +def gst_properties(text): + """Property names of a gst-inspect-1.0 element page.""" + props, inside = set(), False + for line in text.splitlines(): + if re.match(r"^\S", line): + inside = line.startswith("Element Properties") + continue + m = re.match(r"^ ([a-z][a-z0-9-]*)\s*:", line) + if inside and m: + props.add(m.group(1)) + return props + + +# ---- static sources ------------------------------------------------------------------ + +def parse(path): + try: + with open(path, encoding="utf-8") as f: + return ast.parse(f.read(), path) + except FileNotFoundError: + raise Unparseable("%s is missing" % path) + except (SyntaxError, ValueError, UnicodeDecodeError) as e: + raise Unparseable("%s does not parse: %s" % (path, e)) + + +def add_argument_flags(tree): + out = set() + for n in ast.walk(tree): + if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) and n.func.attr == "add_argument": + for a in n.args: + if isinstance(a, ast.Constant) and isinstance(a.value, str) and FLAG.fullmatch(a.value): + out.add(a.value) + return out + + +def args_class_fields(tree): + """--kebab-case of every annotated field of a class named *Args (vLLM registers each + field of FrontendArgs as a flag).""" + out = set() + for n in ast.walk(tree): + if isinstance(n, ast.ClassDef) and n.name.endswith("Args"): + for s in n.body: + if isinstance(s, ast.AnnAssign) and isinstance(s.target, ast.Name) and not s.target.id.startswith("_"): + out.add("--" + s.target.id.replace("_", "-")) + return out + + +def env_names(tree): + for n in tree.body: + target = n.target if isinstance(n, ast.AnnAssign) else (n.targets[0] if isinstance(n, ast.Assign) and len(n.targets) == 1 else None) + if isinstance(target, ast.Name) and target.id == "environment_variables" and isinstance(n.value, ast.Dict): + names = {k.value for k in n.value.keys if isinstance(k, ast.Constant) and isinstance(k.value, str)} + if names: + return names + raise Unparseable("envs.py has no environment_variables dict of env names") + + +def norm_dist(name): + return re.sub(r"[-_.]+", "_", name).lower() + + +def package_version(site, pkg): + for d in sorted(glob.glob(os.path.join(site, "*.dist-info"))): + meta = os.path.join(d, "METADATA") + if not os.path.exists(meta): + continue + with open(meta, encoding="utf-8", errors="replace") as f: + head = f.read(4096) + n = re.search(r"^Name: (.+)$", head, re.M) + v = re.search(r"^Version: (.+)$", head, re.M) + if n and v and norm_dist(n.group(1).strip()) == norm_dist(pkg): + return v.group(1).strip() + try: + import importlib.metadata as md # reads dist-info on sys.path; imports nothing of pkg + return md.version(pkg) + except Exception: + pass + for name in ("_version.py", "version.py"): + p = os.path.join(site, pkg, name) + if os.path.exists(p): + for n in ast.walk(parse(p)): + if isinstance(n, ast.Assign) and isinstance(n.value, ast.Constant) and isinstance(n.value.value, str) \ + and any(isinstance(t, ast.Name) and t.id in ("__version__", "version") for t in n.targets): + return n.value.value + return None + + +def static_package(site, pkg): + """What a package's sources define, read without importing it.""" + root = os.path.join(site, pkg) + if not os.path.isdir(root): + raise Unparseable("no package %s under %s" % (pkg, site)) + doc = {"root": root, "version": package_version(site, pkg), "flags": set(), "envs": set(), "files": []} + if pkg != "vllm": + return doc + engine = os.path.join(root, "engine", "arg_utils.py") + flags = add_argument_flags(parse(engine)) + if not flags: + raise Unparseable("%s has no add_argument flags" % engine) + clis = sorted(glob.glob(os.path.join(root, "entrypoints", "**", "cli_args.py"), recursive=True)) + if not clis: + raise Unparseable("no vllm/entrypoints/**/cli_args.py (the frontend flags) under %s" % root) + for p in clis: + t = parse(p) + flags |= add_argument_flags(t) | args_class_fields(t) + envs = env_names(parse(os.path.join(root, "envs.py"))) + doc.update(flags=flags, envs=envs, files=[engine] + clis + [os.path.join(root, "envs.py")]) + return doc + + +def find_site(pkg, paths): + """The directory holding the package: a sys.path entry, or for an editable install + whose finder maps the name, the parent of the location importlib finds. find_spec + on a top-level name runs the finders only; it does not execute the package.""" + for p in paths: + if p and os.path.isdir(os.path.join(p, pkg)) and (os.path.exists(os.path.join(p, pkg, "__init__.py")) + or os.path.isdir(os.path.join(p, pkg, "engine"))): + return p + try: + import importlib.util + spec = importlib.util.find_spec(pkg) + locs = list(spec.submodule_search_locations or []) if spec else [] + if locs and os.path.isdir(locs[0]): + return os.path.dirname(os.path.abspath(locs[0])) + except (ImportError, ValueError): + pass + raise Unparseable("package %s is not on sys.path (%s)" % (pkg, ":".join(x for x in paths if x))) + + +# ---- comparisons --------------------------------------------------------------------- + +def release(v): + """The X.Y[.Z] a version string starts with (v1.35.0, 0.32.0+cpu, 0.26.0b2, + 1.35.0.20260722-1~ubuntu24.04), or None. + + A setuptools-scm build of a tag with uncommitted changes is the tag's release. + Its default scheme (guess-next-dev, local node-and-date) names a tree at tag + X.Y.Z that has changes X.Y.(Z+1).dev0+g[.d]: dev0 is no commit + after the tag, and the last segment is bumped. intel/llm-scaler-vllm 1.5's + 0.26.1.dev0+g568afb3a1.d20260907.xpu is vLLM v0.26.0 (commit 568afb3a1) with + patches applied. A next version whose last segment is 0 has an unknown tag: None.""" + v = (v or "").strip() + m = re.match(r"v?(\d+\.\d+(?:\.\d+)?)", v) + if not m: + return None + scm = re.fullmatch(r"v?(\d+(?:\.\d+)+)\.dev0\+g[0-9a-f]{7,40}(?:\.[A-Za-z0-9]+)*", v) + if scm: + parts = scm.group(1).split(".") + if int(parts[-1]) == 0: + return None + parts[-1] = str(int(parts[-1]) - 1) + return ".".join(parts) + return m.group(1) + + +def fw_date(data): + """YYYYMMDD from an NPU firmware's version string ("20260630*MTL..." or + "Jun 30 2026*NPU40xx*", the scripts/k3d-gpu npu_fw_date rule).""" + m = re.search(rb"([0-9]{8}|[A-Z][a-z]{2} [ 0-9][0-9] [0-9]{4})\*(MTL|NPU)", data) + if not m: + return None + v = m.group(1).decode() + if v.isdigit(): + return v + mon, day, year = v.split() + i = "JanFebMarAprMayJunJulAugSepOctNovDec".find(mon) + return "%s%02d%02d" % (year, i // 3 + 1, int(day)) if i >= 0 else None + + +def norm_flag(name, driver): + name = name.split("=", 1)[0] + if driver == "vllm" and name.startswith("--"): + name = "--" + name[2:].replace("_", "-") + return name + + +# Drivers whose parser is argparse with allow_abbrev (its default), where k3d-gpu +# emits an abbreviation: bench/h54's sglang `--tp 1` is --tp-size (sglang's own docs +# write `--tp 2`). vLLM's parser allows them too, but the launcher spells every vLLM +# flag out, and an exact match catches a rename that a prefix match would hide. +ABBREV_DRIVERS = {"sglang"} + + +def abbreviation(name, have): + """The option string argparse expands a long option prefix to: the one option + string that begins with it (Python 3.12's argparse; two or more is "ambiguous + option"), else None. Single-dash names are matched exactly. `have` is what the help + lists, so an option hidden from help (argparse.SUPPRESS) cannot make it ambiguous.""" + if not name.startswith("--") or len(name) < 3: + return None + hits = [h for h in have if h.startswith(name)] + return hits[0] if len(hits) == 1 else None + + +def abbreviations(want, flags, driver): + """{name: option} for each wanted flag present only as an abbreviation.""" + if driver not in ABBREV_DRIVERS: + return {} + have = {norm_flag(f, driver) for f in flags} + out = {} + for w in want: + n = norm_flag(w["name"], driver) + if w["kind"] == "flag" and n not in have and abbreviation(n, have): + out[w["name"]] = abbreviation(n, have) + return out + + +def missing(want, flags, envs, driver, lenient_no=False): + have = {norm_flag(f, driver) for f in flags} + short = abbreviations(want, flags, driver) + out = [] + for w in want: + if w["kind"] == "env": + if w["name"] not in envs: + out.append(w) + continue + n = norm_flag(w["name"], driver) + if n in have or w["name"] in short or (lenient_no and n.startswith("--no-") and "--" + n[5:] in have): + continue + out.append(w) + return out + + +def derived_mismatch(derived, facts): + bad = [] + for d in derived: + fact = facts.get(d["of"]) + same = fact is not None and (fact == d["value"] if d["of"] == "firmware" else release(fact) == release(d["value"])) + if not same: + bad.append(dict(d, image=fact)) + return bad + + +# ---- host side ----------------------------------------------------------------------- + +def docker(args, timeout=600): + try: + p = subprocess.run(["docker", *args], capture_output=True, text=True, timeout=timeout) + return p.returncode, p.stdout + p.stderr + except subprocess.TimeoutExpired: + return 124, "timed out after %ds" % timeout + + +def run_image(image, args, entrypoint=None, extra=()): + opts = ["run", "--rm", "--network", "none", *extra] + if entrypoint: + opts += ["--entrypoint", entrypoint] + return docker(opts + [image, *args]) + + +def help_of(image, args, entrypoint=None): + rc, out = run_image(image, args, entrypoint) + if rc != 0: + raise Unparseable("%s%s exited %d: %s" % (entrypoint + " " if entrypoint else "", " ".join(args), rc, tail(out))) + return help_flags(out) + + +def tail(text, n=300): + text = " ".join(text.split()) + return text[-n:] + + +def inside(image, pkg, canary, python="python3"): + rc, out = run_image(image, ["/canary/surface.py", "inside", "--pkg", pkg], python, ("-v", "%s:/canary:ro" % canary)) + try: + doc = json.loads(out[out.index("{"):]) + except ValueError: + raise Unparseable("surface.py inside exited %d: %s" % (rc, tail(out))) + if rc != 0 or "error" in doc: + raise Unparseable(doc.get("error") or "surface.py inside exited %d" % rc) + return doc + + +def check(doc, canary): + image, sv = doc["ref"], doc.get("surface") or {} + driver = sv.get("driver") + want = doc.get("flags", []) + facts, notes, method = {}, [], "help" + flags, envs, lenient = set(), set(), False + try: + if driver == "vllm": + st = inside(image, "vllm", canary, sv.get("python", "python3")) + envs, facts["package"] = set(st["envs"]), st["version"] + try: + flags = help_of(image, ["serve", "--help=all"], "vllm") + except Unparseable as e: + flags, lenient, method = set(st["flags"]), True, "static" + notes.append("vllm serve --help=all did not run here (%s), flags from the sources" % e) + elif driver == "llama-server": + flags = help_of(image, ["--help"]) + elif driver == "llama-convert": + flags = help_of(image, ["/app/convert_hf_to_gguf.py", "--help"], "python3") + rc, out = run_image(image, ["--help"], "/app/llama-quantize") + if "usage:" not in out: + raise Unparseable("llama-quantize --help printed no usage (rc %d): %s" % (rc, tail(out))) + elif driver == "ovms": + flags = help_of(image, ["--help"]) + if sv.get("deb"): + rc, out = run_image(image, ["-W", "-f", "${Version}", sv["deb"]], "dpkg-query") + if rc != 0 or not out.strip(): + raise Unparseable("dpkg-query %s exited %d: %s" % (sv["deb"], rc, tail(out))) + facts["deb"] = out.strip().splitlines()[-1] + if sv.get("firmware"): + facts["firmware"] = firmware_date(image, sv["firmware"]) + elif driver == "gst": + method = "gst-inspect" + device_only = tuple(sv.get("device_only", [])) + for w in want: + if w["kind"] != "element": + continue + el = w["name"] + if el.startswith(device_only): + notes.append("%s registers only on a device: not checked" % el) + flags.add(el) + flags.update(x["name"] for x in want if x["name"].startswith(el + ".")) + continue + rc, out = run_image(image, [el], "gst-inspect-1.0") + if rc == 0: + flags.add(el) + flags.update("%s.%s" % (el, p) for p in gst_properties(out)) + elif driver == "sglang": + flags = help_of(image, ["-m", "sglang.launch_server", "--help"], "python3") + facts["package"] = inside(image, "sglang", canary)["version"] + else: + raise Unparseable("unknown surface driver %r" % driver) + version = probe_version(doc, facts) + except Unparseable as e: + return {"verdict": "unparseable", "detail": "surface: %s" % e, "method": method, "version": None, "missing": []} + lost = missing(want, flags, envs, driver, lenient_no=lenient) + notes += ["%s is argparse's abbreviation of %s" % kv for kv in sorted(abbreviations(want, flags, driver).items())] + bad = derived_mismatch(doc.get("derived", []), facts) + problems = [] + if lost: + problems.append("not in the image: " + ", ".join("%s (%s)" % (m["name"], m["source"]) for m in lost)) + for d in bad: + problems.append("%s says %s, the image's %s is %s" % (d["file"], d["value"], d["of"], d["image"])) + exp = doc.get("expected_version") + if exp and version and (release(version) or version) != (release(exp) or exp): + problems.append("the image reports version %s, its tag %s says %s" % (version, doc.get("tag"), exp)) + ok = "%d names checked (%s)%s" % (len(want), method, ", version %s" % version if version else "") + if notes: + ok += "; " + "; ".join(notes) + if problems: + return {"verdict": "fail", "detail": "surface: " + "; ".join(problems), "method": method, "version": version, + "missing": [m["name"] for m in lost]} + return {"verdict": "pass", "detail": "surface: " + ok, "method": method, "version": version, "missing": []} + + +def probe_version(doc, facts): + probe = doc.get("version_probe") or {} + if "package" in probe: + v = facts.get("package") + if not v: + raise Unparseable("no version for package %s" % probe["package"]) + return v + if "args" in probe: + rc, out = run_image(doc["ref"], probe["args"], probe.get("entrypoint")) + m = re.search(probe["regex"], out) + if not m: + raise Unparseable("%s printed no version matching %r (rc %d): %s" % (" ".join(probe["args"]), probe["regex"], rc, tail(out))) + return m.group("v") + return None # a server endpoint: canary/probe.py reads it + + +def firmware_date(image, path): + name = "canary-fw-%d" % os.getpid() + rc, out = docker(["create", "--name", name, image]) + if rc != 0: + raise Unparseable("docker create: %s" % tail(out)) + try: + with tempfile.TemporaryDirectory() as d: + rc, out = docker(["cp", "-L", "%s:%s" % (name, path), os.path.join(d, "fw")]) + if rc != 0: + raise Unparseable("no %s in the image: %s" % (path, tail(out))) + with open(os.path.join(d, "fw"), "rb") as f: + date = fw_date(f.read()) + finally: + docker(["rm", "-f", name]) + if not date: + raise Unparseable("no build date in %s" % path) + return date + + +def main(): + p = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + sub = p.add_subparsers(dest="cmd", required=True) + c = sub.add_parser("check") + c.add_argument("--engine", required=True) + c.add_argument("--out", required=True) + c.add_argument("--canary", default=os.path.dirname(os.path.abspath(__file__))) + i = sub.add_parser("inside") + i.add_argument("--pkg", required=True) + i.add_argument("--root") + a = p.parse_args() + if a.cmd == "inside": + try: + site = a.root or find_site(a.pkg, sys.path) + doc = static_package(site, a.pkg) + except Unparseable as e: + print(json.dumps({"error": str(e)})) + return 3 + print(json.dumps({"root": doc["root"], "version": doc["version"], "flags": sorted(doc["flags"]), + "envs": sorted(doc["envs"])})) + return 0 + with open(a.engine, encoding="utf-8") as f: + doc = json.load(f) + res = check(doc, os.path.abspath(a.canary)) + with open(a.out, "w", encoding="utf-8") as f: + json.dump(res, f, indent=1) + print(res["detail"]) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/console/plan.py b/console/plan.py index 4a3dcda9..81d5acd1 100755 --- a/console/plan.py +++ b/console/plan.py @@ -101,6 +101,15 @@ def check_overrides(overrides): return out +def image_id(ref): + """name@sha256: for a ref that carries a digest, its tag dropped (the digest + is the image, so name:tag@sha256:X and name@sha256:X are one pin); else ref.""" + name, at, digest = ref.partition("@") + if at and ":" in name.rsplit("/", 1)[-1]: + name = name.rsplit(":", 1)[0] + return name + at + digest + + def cell_status(row, pins): """("green" | "amber" | "grey", reasons).""" if row["source"] == "community": @@ -116,7 +125,7 @@ def cell_status(row, pins): pin = pins.get(row["engine"]["name"]) if pin is None: reasons.append("this release pins no %s image" % row["engine"]["name"]) - elif pin != row["engine"]["image"]: + elif image_id(pin) != image_id(row["engine"]["image"]): reasons.append("engine pin moved since the measurement (cell %s, release %s)" % (row["engine"]["image"], pin)) return ("grey" if reasons else "green"), reasons diff --git a/docs/platforms.md b/docs/platforms.md index ed23ae98..513dabf4 100644 --- a/docs/platforms.md +++ b/docs/platforms.md @@ -343,7 +343,10 @@ the two the move of `eat-up.sh` to `release-1.1.0` had to handle. skips. `fresh-install.sh` (line 419) then deploys the `Qwen/Qwen3Guard-Gen-0.6B` guard. `brownfield.sh` now reads `deploy_guardrails` from the config, as `read_config_file` reads every other toggle, and exports `DEPLOY_GUARDRAILS` before it sources the script; - `agentic-config.cfg` sets it `off`. + `agentic-config.cfg` sets it `off`. With `deploy_guardrails=on`, `deploy-guardrails.yml` + gets a copy of EAT's charts in which the guard's `guardrail-values.yaml` (line 30, `v0.28.0` + by tag only) carries the digest `vllm-values.yaml` pins (`guard-values.py`); the guard has + not run on a k3d-gpu cluster. - vLLM image. #21's `xeon-values.yaml` (line 34) moves `vllm/vllm-openai-cpu` from `v0.24.0` to `v0.28.0`. `vllm-values.yaml` merges over it with no tag check, so it now pins `v0.28.0` by digest (`sha256:197354e6...df9647`, Docker Hub, read 2026-10-29). diff --git a/examples/dataplane/images/pg-zlib-accel.Dockerfile b/examples/dataplane/images/pg-zlib-accel.Dockerfile index f8ebff38..02fdcfbe 100644 --- a/examples/dataplane/images/pg-zlib-accel.Dockerfile +++ b/examples/dataplane/images/pg-zlib-accel.Dockerfile @@ -10,7 +10,7 @@ # Debian trixie packages qatlib 24.02.0, QATzip 1.3.1 (zlib-accel needs >= 1.3.0), # oneTBB 2022.1 and accel-config 4.1.8; QPL is not packaged, so it is built here. -ARG PGVECTOR_IMAGE=pgvector/pgvector:0.8.7-pg18-trixie@sha256:9d9c930220cb9bf2f956d10a8f909cf9973d9672ca0278aef2c1e6facccad2e0 +ARG PGVECTOR_IMAGE=pgvector/pgvector:0.8.7-pg18-trixie@sha256:5895949dfcdf308eb0cd31c950c93c14895c77f33fbedfecf1fa511ab1d4bcd2 ARG QPL_TAG=v1.9.0 ARG ZLIB_ACCEL_TAG=v1.1.0 diff --git a/examples/dataplane/pgvector.yaml b/examples/dataplane/pgvector.yaml index 2666e9ad..3cddfd43 100644 --- a/examples/dataplane/pgvector.yaml +++ b/examples/dataplane/pgvector.yaml @@ -32,7 +32,7 @@ spec: spec: containers: - name: postgres - image: pgvector/pgvector:0.8.7-pg18-trixie@sha256:9d9c930220cb9bf2f956d10a8f909cf9973d9672ca0278aef2c1e6facccad2e0 + image: pgvector/pgvector:0.8.7-pg18-trixie@sha256:5895949dfcdf308eb0cd31c950c93c14895c77f33fbedfecf1fa511ab1d4bcd2 args: - -c - wal_compression=lz4 diff --git a/hack/autobump-branch-reset b/hack/autobump-branch-reset new file mode 100755 index 00000000..795943eb --- /dev/null +++ b/hack/autobump-branch-reset @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# hack/autobump-branch-reset : delete origin's autobump/ branch when its +# .github/workflows/ differ from origin/main's. Run from a checkout whose origin +# push credentials are the workflow's GITHUB_TOKEN, before create-pull-request. +# +# create-pull-request resets an existing bump branch (a PR still open, or a merged +# one whose branch stayed) onto main and force-pushes it. GitHub refuses that push +# from GITHUB_TOKEN (it has no `workflows` permission) when the branch's workflow +# files differ from main's, though the bump commit touches none. Deleting the +# branch closes its open PR; create-pull-request then cuts a fresh branch off main +# and opens a new PR, whose dispatched CI runs main's current workflows. +set -euo pipefail +b=${1:-} +case $b in + autobump/?*) ;; + *) echo "usage: hack/autobump-branch-reset autobump/ (refusing '${b}')" >&2; exit 2;; +esac +git ls-remote --exit-code --heads origin "refs/heads/$b" >/dev/null \ + || { rc=$?; [ "$rc" -eq 2 ] && { echo "$b: not on origin; nothing to reset"; exit 0; }; exit "$rc"; } +git fetch -q --depth=1 origin "+refs/heads/main:refs/remotes/origin/main" "+refs/heads/$b:refs/remotes/origin/$b" +if git diff --quiet "origin/main" "origin/$b" -- .github/workflows; then + echo "$b: its workflows match main's; kept" +else + echo "$b: its workflows differ from main's; deleting it (its open PR closes, and a fresh one opens)" + git push origin --delete "$b" +fi diff --git a/hack/bot-ci-dispatch b/hack/bot-ci-dispatch new file mode 100755 index 00000000..f756a589 --- /dev/null +++ b/hack/bot-ci-dispatch @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# hack/bot-ci-dispatch [--branch B] [--workflow ci.yml]: give the bot's PRs real CI. +# +# A PR that GITHUB_TOKEN opened or pushed gets pull_request runs that wait for a +# maintainer's approval (conclusion action_required); a workflow_dispatch run +# needs none. For each open PR into main from a same-repo autobump/* branch opened +# by github-actions[bot] (never a fork, never a human's branch), oldest first: +# - no run of the workflow at the head SHA except held ones: dispatch the +# workflow on the branch (gh workflow run --ref); +# - the latest run at the head failed or timed out on its first attempt, and no +# run there was re-run yet: re-run its failed jobs, once per SHA; +# - otherwise: leave it. +# A run at the head is a pull_request run, or a workflow_dispatch run on the branch. +# +# Runner pool: one ci.yml run is ~39 jobs and a public repo gets 20-40 concurrent +# jobs, so it dispatches or re-runs only while fewer than BOT_CI_MAX_INFLIGHT +# (default 1) runs of the workflow on autobump/* branches are queued or running, +# and not at all while a ci.yml run of a human PR is queued. A deferred PR is +# picked up by the next call (.github/workflows/bot-sweep.yml, hourly). +# +# A branch whose workflow file lacks the workflow_dispatch trigger (cut before +# ci.yml had it) is skipped; the nightly's hack/autobump-branch-reset recreates it. +# Prints one line per decision. Idempotent. Exit 0, or 1 when a gh call failed +# (the rest still ran), 2 on bad usage. +set -uo pipefail + +usage() { echo "usage: hack/bot-ci-dispatch [--branch autobump/] [--workflow ci.yml]" >&2; exit 2; } +wf=ci.yml only= +while [ $# -gt 0 ]; do + case $1 in + --branch) [ $# -ge 2 ] || usage; only=$2; shift 2;; + --workflow) [ $# -ge 2 ] || usage; wf=$2; shift 2;; + -h|--help) sed -n '2,/^set /{/^set /d;s/^# \{0,1\}//;p}' "$0"; exit 0;; + *) usage;; + esac +done +cap=${BOT_CI_MAX_INFLIGHT:-1} +[[ $cap =~ ^[0-9]+$ ]] || { echo "BOT_CI_MAX_INFLIGHT must be a whole number, got '$cap'" >&2; exit 2; } +RUNF=databaseId,event,status,conclusion,headBranch,attempt,createdAt +rc=0 + +prs=$(gh pr list --state open --base main --limit 200 \ + --json number,headRefName,headRefOid,baseRefName,isCrossRepository,author) \ + || { echo "error: cannot list open PRs"; exit 1; } +cands=$(jq -r --arg only "$only" ' + .[] | select(.isCrossRepository == false and .baseRefName == "main" + and (.headRefName | startswith("autobump/")) + and (.author.login | IN("app/github-actions", "github-actions[bot]", "github-actions")) + and (.author.is_bot != false) + and ($only == "" or .headRefName == $only)) + | "\(.number) \(.headRefName) \(.headRefOid)"' <<<"$prs" | sort -n) +[ -n "$cands" ] || { echo "no open github-actions[bot] PR from ${only:-an autobump/ branch}"; exit 0; } + +# The pool: bot runs of the workflow in flight, and queued ci.yml runs of human PRs. +recent=$(gh run list -w "$wf" -L 100 --json status,event,headBranch) || { echo "error: cannot list $wf runs"; exit 1; } +inflight=$(jq '[.[] | select(.status != "completed" and ((.headBranch // "") | startswith("autobump/")))] | length' <<<"$recent") +if [ "$wf" != ci.yml ]; then + recent=$(gh run list -w ci.yml -L 100 --json status,event,headBranch) || { echo "error: cannot list ci.yml runs"; exit 1; } +fi +human=$(jq '[.[] | select(.event == "pull_request" and (.status | IN("queued", "requested", "pending", "waiting")) + and ((.headBranch // "") | startswith("autobump/") | not))] | length' <<<"$recent") +[[ $inflight =~ ^[0-9]+$ && $human =~ ^[0-9]+$ ]] || { echo "error: cannot read the run pool from gh run list"; exit 1; } +# busy LINE: print LINE with the reason and succeed when the pool has no room. +busy() { + if [ "$human" -gt 0 ]; then echo "$1: deferred, $human ci.yml run(s) of human PRs queued"; return 0; fi + if [ "$inflight" -ge "$cap" ]; then echo "$1: deferred, $inflight bot $wf run(s) in flight (cap $cap)"; return 0; fi + return 1 +} + +while read -r num br sha; do + tag="#$num $br ${sha:0:7}" + runs=$(gh run list -w "$wf" -c "$sha" -L 100 --json "$RUNF") \ + || { echo "$tag: error: cannot list its $wf runs"; rc=1; continue; } + # " " + read -r count held id st co att reran <<<"$(jq -r --arg b "$br" ' + def word: if . == null or . == "" then "-" else tostring end; + [.[] | select(.event == "pull_request" or (.event == "workflow_dispatch" and .headBranch == $b))] as $at + | ($at | map(select(.conclusion != "action_required"))) as $e + | ($e | max_by(.createdAt // "")) as $l + | "\($e | length) \(($at | length) - ($e | length)) \($l.databaseId | word) \($l.status | word) \($l.conclusion | word) \($l.attempt // 1) \(any($e[]; (.attempt // 1) > 1))"' <<<"$runs")" + if [ "$count" -eq 0 ]; then + why="no $wf run at the head"; [ "$held" -gt 0 ] && why="only held $wf runs at the head" + busy "$tag: $why" && continue + if out=$(gh workflow run "$wf" --ref "$br" 2>&1); then + inflight=$((inflight + 1)) + # Wait until the run shows, so that a call right after this one sees it. + new= + for _ in 1 2 3 4 5 6 7 8 9 10; do + new=$(gh run list -w "$wf" -b "$br" -e workflow_dispatch -c "$sha" -L 1 --json databaseId -q '.[0].databaseId // empty' 2>/dev/null) + [ -n "$new" ] && break; sleep 3 + done + echo "$tag: $why; dispatched $wf${new:+ (run $new)}" + elif grep -qi 'workflow_dispatch' <<<"$out"; then + echo "$tag: skipped, $wf on $br has no workflow_dispatch trigger (the nightly recreates the branch from main)" + else + echo "$tag: error: dispatching $wf failed: $(tr '\n' ' ' <<<"$out")"; rc=1 + fi + elif [ "$st" = completed ] && [[ $co =~ ^(failure|timed_out)$ ]] && [ "$att" = 1 ] && [ "$reran" = false ]; then + busy "$tag: $wf run $id $co on attempt 1" && continue + if out=$(gh run rerun "$id" --failed 2>&1); then + inflight=$((inflight + 1)); echo "$tag: $wf run $id $co on attempt 1; re-ran its failed jobs" + else + echo "$tag: error: re-running run $id failed: $(tr '\n' ' ' <<<"$out")"; rc=1 + fi + elif [ "$st" = completed ] && [ "$co" != success ]; then + echo "$tag: $wf run $id is $co (attempt $att); left for review" + else + echo "$tag: $wf run $id at the head is $st/$co; nothing to do" + fi +done <<<"$cands" +exit "$rc" diff --git a/hack/ci-tools.env b/hack/ci-tools.env new file mode 100644 index 00000000..bd38eb9a --- /dev/null +++ b/hack/ci-tools.env @@ -0,0 +1,34 @@ +# Tool versions the CI and release workflows install, one KEY=value per line. They +# live here, outside .github/workflows/, so a bump is a change to this file: a token +# without the `workflows` permission can push it, and pins/classes classes it ours. +# A job loads them in its first step after checkout: +# grep -E '^[A-Z0-9_]+=' hack/ci-tools.env >> "$GITHUB_ENV" +# and reads them as ${{ env.KEY }} (action inputs) or $KEY (run steps). +# tests/ci_wiring_test.sh fails on a version literal left in a workflow. + +# prometheus/prometheus: promtool checks the telemetry rules (ci.yml telemetry-tests). +PROMETHEUS_VERSION=3.15.1 +PROMETHEUS_SHA256=6a726885328ad9efebbf36e876ce2fb6225468a71fd1e7ce3ba666d5f4d97a84 + +# yannh/kubeconform: the release binary (chat-ui-manifest, kserve-example) and the +# image (telemetry-tests and tests/kubeconform.sh, agent_kubeconform.sh, +# share_kubeconform.sh), the same release. +KUBECONFORM_VERSION=v0.8.0 +KUBECONFORM_SHA256=9bc2bffbf71f261128533edaf912153948b7ff238f9a531ae6d34466ec287883 +KUBECONFORM_IMAGE=ghcr.io/yannh/kubeconform:v0.8.0@sha256:faffaf43f95aa6425306e1ab8d6fcad72acb9049158f38e574c085ea1ec0f64e + +# Chrome for Testing: report.html's offline load (report-html). +CHROME_VERSION=156.0.8078.73 + +# python-jsonschema: the facts/v1 schema cross-check (console-tests). +JSONSCHEMA_VERSION=4.26.0 + +# helm v4 for the KServe example's install path (kserve-example). Rendering share/ +# uses the v3 line in hack/tools.env. +HELM4_VERSION=v4.3.1 + +# koalaman/shellcheck, run by ludeeus/action-shellcheck (shellcheck). +SHELLCHECK_VERSION=v0.11.0 + +# pypa/packaging for hack/bump-convert-pins (build-and-push.yml bump-convert-pins). +PACKAGING_VERSION=26.3 diff --git a/hack/ci_alarm b/hack/ci_alarm new file mode 100755 index 00000000..37e20c69 --- /dev/null +++ b/hack/ci_alarm @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# ci_alarm: one GitHub issue per alarm key, labelled ci-alarm. +# +# hack/ci_alarm open <body-file> +# Keeps one open issue titled "[ci-alarm] <key>: <title>", labelled ci-alarm +# (created with `gh label create --force` when the issue is filed), assigned +# to $ALARM_OWNER with an @mention in its body. When the key's issue is +# already open: a new body replaces the issue body and is posted as a +# comment; the same body comments again only once 24 h have passed since +# the last comment (or since the issue was opened). +# hack/ci_alarm close <key> <comment> +# Comments and closes the key's open issue; no open issue is a no-op. +# hack/ci_alarm list +# Prints the key of every open ci-alarm issue, one per line. +# +# A key is [A-Za-z0-9._:/@+-]+ (no spaces), e.g. red:build-and-push.yml, +# release:0.1.76, watchdog-error. gh picks the repository from GH_REPO, else the +# checkout's remote. +# +# When the repository has issues disabled, `open` prints a ::error workflow +# annotation with the title and appends the body to $GITHUB_STEP_SUMMARY, and +# every command exits 3. Exit 2 is a usage error, 1 any other gh failure. +# +# Env: ALARM_OWNER (default 88plug), CI_ALARM_NOW (epoch seconds, for tests), +# GITHUB_STEP_SUMMARY. +set -euo pipefail + +OWNER=${ALARM_OWNER:-88plug} +LABEL=ci-alarm +NOW=${CI_ALARM_NOW:-$(date -u +%s)} +DAY=86400 + +usage() { sed -n '2,24p' "$0" >&2; exit 2; } +TMP=$(mktemp -d "${TMPDIR:-/tmp}/ci_alarm.XXXXXX") +trap 'rm -rf "$TMP"' EXIT + +# gh_run ARGS...: run gh; on failure print its stderr and exit 3 (issues +# disabled) or 1 (anything else). +gh_run() { + if gh "$@" > "$TMP/gh.out" 2> "$TMP/gh.err"; then + cat "$TMP/gh.out" + return 0 + fi + cat "$TMP/gh.err" >&2 + if grep -qi 'disabled issues' "$TMP/gh.err"; then exit 3; fi + exit 1 +} + +# open_issues: JSON array of the open ci-alarm issues. +open_issues() { + gh_run issue list --label "$LABEL" --state open --limit 200 --json number,title,body,comments,createdAt +} + +# issue_for KEY: the key's open issue (lowest number) as JSON, or nothing. +issue_for() { + open_issues > "$TMP/issues.json" + jq -c --arg p "[ci-alarm] $1: " \ + 'map(select(.title | startswith($p))) | sort_by(.number) | .[0] // empty' "$TMP/issues.json" +} + +check_key() { + [[ "$1" =~ ^[A-Za-z0-9._:/@+-]+$ ]] || { echo "ci_alarm: bad key '$1' (want [A-Za-z0-9._:/@+-]+)" >&2; exit 2; } +} + +cmd_open() { + [ $# -eq 3 ] || usage + local key=$1 title=$2 file=$3 full issue num old new last + check_key "$key" + [ -n "$title" ] || { echo "ci_alarm: empty title" >&2; exit 2; } + [ -r "$file" ] || { echo "ci_alarm: cannot read body file '$file'" >&2; exit 2; } + full="[ci-alarm] $key: $title" + # shellcheck disable=SC2016 # markdown backticks, not a command substitution + { printf '@%s ci-alarm `%s`\n\n' "$OWNER" "$key"; cat "$file"; } > "$TMP/body" + + issue=$(issue_for "$key") || exit $? + if [ -z "$issue" ]; then + gh_run label create "$LABEL" --force --color B60205 \ + --description "Automation alarm filed by hack/ci_alarm" >/dev/null + gh_run issue create --title "$full" --body-file "$TMP/body" --label "$LABEL" --assignee "$OWNER" + return 0 + fi + + num=$(jq -r .number <<< "$issue") + # GitHub may store the body with CRLF and without the trailing newline. + old=$(jq -r '.body | gsub("\r\n"; "\n") | sub("\\s+$"; "")' <<< "$issue") + new=$(jq -Rrs 'gsub("\r\n"; "\n") | sub("\\s+$"; "")' "$TMP/body") + if [ "$old" != "$new" ] || [ "$(jq -r .title <<< "$issue")" != "$full" ]; then + gh_run issue edit "$num" --title "$full" --body-file "$TMP/body" >/dev/null + gh_run issue comment "$num" --body-file "$TMP/body" >/dev/null + echo "ci_alarm: $key updated (#$num)" + return 0 + fi + last=$(jq -r '[.createdAt] + [.comments[].createdAt] | max' <<< "$issue") + if [ $(( NOW - $(date -u -d "$last" +%s) )) -ge "$DAY" ]; then + printf '@%s still open: %s\n' "$OWNER" "$full" > "$TMP/reminder" + gh_run issue comment "$num" --body-file "$TMP/reminder" >/dev/null + echo "ci_alarm: $key reminded (#$num)" + else + echo "ci_alarm: $key unchanged (#$num)" + fi +} + +cmd_close() { + [ $# -eq 2 ] || usage + local key=$1 comment=$2 issue num + check_key "$key" + issue=$(issue_for "$key") || exit $? + if [ -z "$issue" ]; then + echo "ci_alarm: no open alarm $key" + return 0 + fi + num=$(jq -r .number <<< "$issue") + gh_run issue close "$num" --comment "$comment" >/dev/null + echo "ci_alarm: $key closed (#$num)" +} + +cmd_list() { + [ $# -eq 0 ] || usage + open_issues > "$TMP/issues.json" + jq -r '.[].title | capture("^\\[ci-alarm\\] (?<k>[^ ]+): ") | .k' "$TMP/issues.json" +} + +# Issues disabled: `open` still reaches the run's log and summary. +annotate() { + local key=$1 title=$2 file=$3 + echo "::error title=ci-alarm ${key}::${title} (not filed: this repository has issues disabled)" + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + # shellcheck disable=SC2016 # markdown backticks + { printf '### ci-alarm `%s`: %s\n\n' "$key" "$title"; cat "$file"; echo; } >> "$GITHUB_STEP_SUMMARY" + fi +} + +[ $# -ge 1 ] || usage +cmd=$1; shift +case "$cmd" in + open) + set +e + (cmd_open "$@") + rc=$? + set -e + if [ "$rc" -eq 3 ] && [ $# -eq 3 ]; then annotate "$@"; fi + exit "$rc" ;; + close) cmd_close "$@" ;; + list) cmd_list "$@" ;; + *) usage ;; +esac diff --git a/hack/ci_alarm_signatures.tsv b/hack/ci_alarm_signatures.tsv new file mode 100644 index 00000000..b9c4d373 --- /dev/null +++ b/hack/ci_alarm_signatures.tsv @@ -0,0 +1,8 @@ +# Known failure signatures for hack/watchdog: <Python regex> TAB <hint>. +# The watchdog scans a failed run's log and puts the hint of the first matching +# line (top to bottom in this file) in the alarm body. Lines starting with # are comments. +registry serves sha256 An upstream tag was re-pushed (the registry serves a new digest for a pinned tag): repin with scripts/images-lock repin, then update and check. +without .workflows. permission A bot push touched .github/workflows/: GITHUB_TOKEN cannot create or update workflow files. Delete the stale autobump branch (its workflows differ from main) or move the pin out of the workflow file. +refusing to allow a GitHub App A bot push touched .github/workflows/: GITHUB_TOKEN cannot create or update workflow files. Delete the stale autobump branch (its workflows differ from main) or move the pin out of the workflow file. +429 Too Many Requests Docker Hub rate limit: retry later; the release build retries 3 times with backoff. +non-fast-forward Release race: another writer moved main between fetch and push; the next run releases from the new tip. diff --git a/hack/gate_lib.sh b/hack/gate_lib.sh new file mode 100644 index 00000000..6bdb82e1 --- /dev/null +++ b/hack/gate_lib.sh @@ -0,0 +1,186 @@ +# shellcheck shell=bash +# gate_lib.sh: what the merge gate (hack/gate_merge.sh) reads to decide a PR is +# green and current, for sourcing. Run from inside the repository; gh and git do +# the reading. +# +# READ jq program over `gh pr view --json headRefOid,statusCheckRollup` +# (takes --argjson held_ok BOOL): "<sha> <green> <terminal> <total> +# <non-green names>", the latest entry per check. +# RUNS jq program over `gh run list -c SHA -e pull_request --json +# workflowName,status,conclusion,createdAt` (takes --argjson +# held_ok BOOL): "<green|pending|failed> <bad names>", the latest +# run per workflow. +# ci_status SHA BR green | pending | failed | none: ci.yml at SHA, through a +# pull_request run or a workflow_dispatch run on branch BR. +# ci_eval SHA BR "<ci_status> <held_ok> <latest run>", held_ok as below. +# base_ok SHA main is an ancestor of SHA, or main moved only in the ledger or +# by release-only bot commits, or SHA changes only the ledger. +# rules_stale SHA the tree main + SHA would merge to carries a +# console/ledger-rules.json its EXPERIMENTS.md does not generate. +# open_alarms the open ci-alarm issues as a JSON array (gh issue list). +# gated_paths reads paths on stdin, prints those a merge must not ship while +# an alarm is open and not acked (see below). +# +# held_ok: a PR that GITHUB_TOKEN opened or pushed gets pull_request runs held for +# approval (conclusion action_required, no jobs). hack/bot-ci-dispatch runs ci.yml +# on the PR branch by workflow_dispatch instead. Once the latest such dispatched +# run at the SHA succeeded, a held entry of workflow 'CI' or 'Auto-merge Dependabot +# PRs' no longer counts as terminal (READ) or failed (RUNS). Without it, a held +# entry stays terminal, as for a fork PR. + +GATE_LIB_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# The workflows whose held pull_request runs a green dispatched ci.yml run stands in for. +GATE_HELD_WORKFLOWS='["CI", "Auto-merge Dependabot PRs"]' + +# One snapshot: "<sha> <green> <terminal> <total> <non-green names>", latest run per check. +# An in-progress CheckRun reports conclusion "", a legacy StatusContext only .state. +# READ and RUNS are jq programs ($names are jq's) for the gate that sources this file. +# shellcheck disable=SC2016,SC2034 +READ='def green: IN("SUCCESS", "SKIPPED", "NEUTRAL"); + def terminal: IN("FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "STARTUP_FAILURE", "ACTION_REQUIRED", "STALE"); + ('"$GATE_HELD_WORKFLOWS"') as $held + | .headRefOid as $sha + | [.statusCheckRollup[] + | {k: [.__typename, (.name // .context), (.workflowName // "")], t: (.startedAt // ""), n: (.name // .context), + w: (.workflowName // ""), + v: ((if (.conclusion // "") != "" then .conclusion else (.state // "PENDING") end) | ascii_upcase)} + | select(($held_ok and .v == "ACTION_REQUIRED" and (.w | IN($held[]))) | not)] + | [group_by(.k)[] | if any(.v | (green or terminal) | not) then .[0] + {v: "PENDING"} else max_by(.t) end] as $c + | ($c | map(select(.v | green)) | length) as $g + | ($c | map(select(.v | terminal))) as $b + | "\($sha) \($g) \($b | length) \($c | length) \($b | map("\(.n)=\(.v)") | join(","))"' +# Every pull_request workflow run for a SHA, latest per workflow: "<state> <bad names>", +# state = green | pending | failed. +# shellcheck disable=SC2016,SC2034 +RUNS='('"$GATE_HELD_WORKFLOWS"') as $held + | [.[] | select(($held_ok and .conclusion == "action_required" and (.workflowName | IN($held[]))) | not)] + | [group_by(.workflowName)[] | max_by(.createdAt // "")] as $r + | ($r | map(select(.status != "completed"))) as $p + | ($r | map(select(.status == "completed" and (.conclusion | IN("success", "skipped", "neutral") | not)))) as $f + | if ($f | length) > 0 then "failed \($f | map("\(.workflowName)=\(.conclusion)") | join(","))" + elif ($p | length) > 0 then "pending -" else "green -" end' +# ci.yml runs at a SHA: $p its pull_request runs, $d its workflow_dispatch runs, $b the +# PR branch. Held runs (action_required) are not attempts: the latest completed other +# run decides, a queued or running one means pending, and held runs alone are failed. +# shellcheck disable=SC2016 +CI_EVAL='def ok: .conclusion == "success"; + def word: if . == null or . == "" then "-" else . end; + ($p | map(. + {via: "pull_request"})) + ($d | map(select(.headBranch == $b) | . + {via: "workflow_dispatch"})) as $all + | ($all | map(select(.conclusion != "action_required"))) as $e + | ($e | map(select(.status == "completed")) | max_by(.createdAt // "")) as $last + | ($e | map(select(.via == "workflow_dispatch" and .status == "completed")) | max_by(.createdAt // "")) as $dl + | (if $last != null and ($last | ok) then "green" + elif any($e[]; .status != "completed") then "pending" + elif $last != null or ($all | length) > 0 then "failed" + else "none" end) as $s + | ($s == "green" and $dl != null and ($dl | ok)) as $held_ok + | ($last // ($all | max_by(.createdAt // "")) // {}) as $show + | "\($s) \($held_ok) \($show.via | word):\($show.status | word)/\($show.conclusion | word)"' +ci_eval() { + local p d + p=$(gh run list -w ci.yml -c "$1" -e pull_request -L 100 --json event,headBranch,status,conclusion,createdAt 2>/dev/null) || p= + d=$(gh run list -w ci.yml -c "$1" -e workflow_dispatch -b "$2" -L 100 --json event,headBranch,status,conclusion,createdAt 2>/dev/null) || d= + jq -rn --arg b "$2" --argjson p "${p:-[]}" --argjson d "${d:-[]}" "$CI_EVAL" +} +ci_status() { local s; read -r s _ <<<"$(ci_eval "$1" "$2")"; echo "${s:-none}"; } + +# base_ok SHA: main is an ancestor of SHA, or main moved only in the ledger (or by +# release-only bot commits), or the PR is ledger-only. +code() { git diff --name-only "$1" "$2" | grep -qvxE 'EXPERIMENTS\.md|docs/provenance\.md'; } +# release_only C: C is one of the release commits auto-release.yml and its followers +# push to main: a 'release: ... [skip release]' subject by 88plug-bot or +# github-actions[bot], touching only the release files, and in scripts/k3d-gpu only +# its VERSION="..." line. +release_only() { + local c=$1 f + case $(git log -1 --format=%s "$c") in 'release:'*'[skip release]'*) ;; *) return 1;; esac + case $(git log -1 --format=%an "$c") in 88plug-bot|'github-actions[bot]') ;; *) return 1;; esac + while IFS= read -r f; do + case $f in + packaging/aur/PKGBUILD|packaging/aur/.SRCINFO|Formula/k3d-gpu.rb|packaging/install.sh|console/ledger-rules.json) ;; + scripts/k3d-gpu) + git diff -U0 "$c^" "$c" -- scripts/k3d-gpu \ + | awk '/^@@/ {h = 1; next} h && /^[-+]/ && !/^[-+]VERSION="/ {bad = 1} END {exit bad ? 0 : 1}' && return 1;; + *) return 1;; + esac + done < <(git diff --name-only "$c^" "$c") + return 0 +} +# main_code MB: since MB, main changed a file outside the ledger in a commit that is +# not release-only. When no commit can be listed, that counts as a change. +main_code() { + code "$1" origin/main || return 1 + local c n=0 + while read -r c; do + n=$((n + 1)) + release_only "$c" && continue + code "$c^" "$c" && return 0 + done < <(git rev-list "$1..origin/main") + [ "$n" -eq 0 ] +} +base_ok() { + git fetch -q origin || return 1 + git merge-base --is-ancestor origin/main "$1" && return 0 + local mb; mb=$(git merge-base origin/main "$1") || return 1 + ! { main_code "$mb" && code "$mb" "$1"; } +} + +# rules_stale SHA: the tree main + SHA would merge to carries a console/ledger-rules.json +# that EXPERIMENTS.md there does not generate (read with that tree's own model_opt.py). +# Unknown (a conflict, no such files) is not stale: a conflict syncs anyway. +rules_stale() { + local tree d rc=1 + tree=$(git merge-tree --write-tree origin/main "$1" 2>/dev/null | head -1) + [ -n "$tree" ] || return 1 + d=$(mktemp -d) + if git show "$tree:console/model_opt.py" > "$d/model_opt.py" 2>/dev/null \ + && git show "$tree:console/convert-pins.json" > "$d/convert-pins.json" 2>/dev/null \ + && git show "$tree:console/ledger-rules.json" > "$d/have.json" 2>/dev/null \ + && git show "$tree:EXPERIMENTS.md" > "$d/EXPERIMENTS.md" 2>/dev/null \ + && [ -s "$d/model_opt.py" ] && [ -s "$d/have.json" ] \ + && python3 "$d/model_opt.py" ledger-rules --ledger "$d/EXPERIMENTS.md" --out "$d/want.json" >/dev/null 2>&1 \ + && ! cmp -s "$d/have.json" "$d/want.json"; then + rc=0 + fi + rm -rf "$d" + return "$rc" +} + +# open_alarms: the open ci-alarm issues, [{number, title, url, labels}]; fails when gh does. +open_alarms() { + local a + a=$(gh issue list --label ci-alarm --state open --limit 100 --json number,title,url,labels) || return 1 + jq -c 'if type == "array" then . else error("not an array") end' <<<"${a:-[]}" +} +# gated_paths: of the paths on stdin, print those a merge must not ship while an +# alarm is open and not acked: .github/** and the shipped set. A PR that changes +# only EXPERIMENTS.md, docs/, bench/ or tests/ (the ledger work) prints nothing. +# Shipped set: packaging/, scripts/, share/, Dockerfile, console/, images.lock, plus +# every pattern in hack/release-paths (one per line, # comments, dir/ or dir/** for +# a tree, else a shell glob; an executable file is run for its lines). It is read +# from $GATE_RELEASE_PATHS, else next to this file, else the repository's hack/. +gated_paths() { + local paths p f pats=(packaging/ scripts/ share/ Dockerfile console/ images.lock) rp + paths=$(grep -v '^$') + [ -n "$paths" ] || return 0 + grep -qvE '^(EXPERIMENTS\.md|(docs|bench|tests)/.*)$' <<<"$paths" || return 0 + rp=${GATE_RELEASE_PATHS:-} + [ -n "$rp" ] || for f in "$GATE_LIB_DIR/release-paths" hack/release-paths; do [ -f "$f" ] && { rp=$f; break; }; done + if [ -n "$rp" ] && [ -f "$rp" ]; then + while IFS= read -r p; do + p=${p%%#*}; p=${p#"${p%%[![:space:]]*}"}; p=${p%"${p##*[![:space:]]}"}; p=${p#- }; p=${p//[\"\']/} + [ -n "$p" ] && pats+=("$p") + done < <(if [ -x "$rp" ]; then "$rp"; else cat "$rp"; fi) + fi + while IFS= read -r f; do + case $f in .github/*) echo "$f"; continue;; esac + for p in "${pats[@]}"; do + case $p in */\*\*) p=${p%\*\*};; esac + # shellcheck disable=SC2254 # p is a glob on purpose + case $p in + */) case $f in "$p"*) echo "$f"; break;; esac;; + *) case $f in $p|"$p"/*) echo "$f"; break;; esac;; + esac + done + done <<<"$paths" +} diff --git a/hack/gate_merge.sh b/hack/gate_merge.sh index 94f131bd..6bfcbbaa 100755 --- a/hack/gate_merge.sh +++ b/hack/gate_merge.sh @@ -2,14 +2,28 @@ # gate_merge.sh <pr> <subject> <body>: keep the PR branch current with main # (ledger/provenance conflicts resolved keep-both; anything else -> stop), # then squash-merge the head SHA whose checks were read. Up to 5 sync rounds. +# The checks it reads live in hack/gate_lib.sh, which it sources from its own +# directory, else from the repository's hack/. # # Green means: every latest rollup entry is SUCCESS, SKIPPED or NEUTRAL (a -# check with any queued or running attempt is pending); the ci.yml -# pull_request run for that exact SHA completed with success; and the latest -# run of every other pull_request workflow for that SHA completed with -# success, skipped or neutral. A terminal non-green entry (FAILURE, ERROR, -# CANCELLED, TIMED_OUT, STARTUP_FAILURE, ACTION_REQUIRED, STALE) or a failed -# workflow run exits 1. +# check with any queued or running attempt is pending); ci.yml for that exact +# SHA succeeded, through a pull_request run or a workflow_dispatch run on the PR +# branch (the latest completed one decides); and the latest run of every other +# pull_request workflow for that SHA completed with success, skipped or neutral. +# A terminal non-green entry (FAILURE, ERROR, CANCELLED, TIMED_OUT, +# STARTUP_FAILURE, ACTION_REQUIRED, STALE), a failed workflow run or a failed +# ci.yml exits 1. +# +# Bot PRs: a PR that GITHUB_TOKEN opened or pushed gets pull_request runs held +# for approval (ACTION_REQUIRED). hack/bot-ci-dispatch runs ci.yml on its branch +# by workflow_dispatch instead; once that run is green, the held entries of 'CI' +# and 'Auto-merge Dependabot PRs' no longer block. Held runs alone still exit 1. +# +# Alarms: the gate first prints every open ci-alarm issue. While any of them +# lacks the label alarm-ack, it refuses (exit 4) a PR that changes .github/ or a +# shipped path (see gated_paths in gate_lib.sh), at the start and again right +# before merging; a PR that changes only EXPERIMENTS.md, docs/, bench/ or tests/ +# is never refused. Read the alarm, then ack it: gh issue edit N --add-label alarm-ack. # # Derived file: console/ledger-rules.json is EXPERIMENTS.md's rule rows as packages # ship them. A ledger-only PR (or base) merges without a CI re-run, so the gate @@ -19,9 +33,10 @@ # Moved base: if main is no longer an ancestor of the head, and both main # (since the merge base) and the PR change files other than EXPERIMENTS.md and # docs/provenance.md, the gate syncs the branch so CI re-runs on the merged -# head. A base that moved only in the ledger, or under a ledger-only PR, is -# merged without a re-run. Another PR can still land between this check and -# the merge; that window stays open. +# head. A base that moved only in the ledger or by release-only bot commits +# ('release: ... [skip release]' on the release files), or under a ledger-only +# PR, is merged without a re-run. Another PR can still land between this check +# and the merge; that window stays open. # # The merge carries --match-head-commit, so GitHub merges only if the head is # still that SHA. On a refusal the gate prints GitHub's reason and re-reads the @@ -29,8 +44,11 @@ # sleeps, and the next round re-checks mergeable (sync on a conflict) and the # base before merging again. # +# Exit: 0 merged, 1 failed or gave up, 2 gh lacks --match-head-commit, 4 refused +# for an open alarm. # Env: GATE_REPO (default: the caller's git toplevel), GATE_WT_ROOT (sync # worktree parent, default $TMPDIR), GATE_POLLS (polls per round, default 60), +# GATE_RELEASE_PATHS (shipped-path patterns, default hack/release-paths), # GATE_SELF_DIR (a directory removed on exit; set by the scratchpad shim). set -u; pr=$1; subj=$2; body=$3 HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) @@ -42,9 +60,38 @@ cleanup() { if [ -n "${GATE_SELF_DIR:-}" ]; then rm -rf "$GATE_SELF_DIR"; fi } trap cleanup EXIT +for lib in "$HERE/gate_lib.sh" hack/gate_lib.sh ""; do [ -f "$lib" ] && break; done +[ -n "$lib" ] || { echo "gate_lib.sh not found next to $0 or in $PWD/hack; copy it beside the gate"; exit 1; } +# shellcheck source=hack/gate_lib.sh +. "$lib" gh pr merge --help 2>/dev/null | grep -q -- --match-head-commit \ || { echo "gh pr merge lacks --match-head-commit; refusing to merge unpinned"; exit 2; } br=$(gh pr view "$pr" --json headRefName -q .headRefName) || exit 1 +# alarm_check [list]: with `list`, print every open ci-alarm issue. Exit 4 when one +# is not acked and the PR changes a gated path (or its paths cannot be read). +alarm_check() { + local a un paths gated e + e=$(mktemp) + if ! a=$(open_alarms 2>"$e"); then + echo "WARNING: could not list the open ci-alarm issues ($(tr '\n' ' ' < "$e" | sed 's/ *$//')); the alarm check was skipped" + rm -f "$e"; return 0 + fi + rm -f "$e" + [ "${1:-}" = list ] && jq -r '.[] | "open ci-alarm #\(.number): \(.title) \(.url)\(if any(.labels[]; .name == "alarm-ack") then " (acked)" else " (NOT acked)" end)"' <<<"$a" + un=$(jq -r '.[] | select(any(.labels[]; .name == "alarm-ack") | not) | " #\(.number) \(.title) \(.url)"' <<<"$a") + [ -n "$un" ] || return 0 + if paths=$(gh api --paginate "repos/{owner}/{repo}/pulls/$pr/files" -q '.[].filename' 2>/dev/null); then + gated=$(gated_paths <<<"$paths") + [ -n "$gated" ] || return 0 + echo "PR$pr REFUSED: it changes $(head -5 <<<"$gated" | paste -sd, -)$([ "$(wc -l <<<"$gated")" -gt 5 ] && echo ', ...') while these ci-alarm issues are open and not acked:" + else + echo "PR$pr REFUSED: its changed files could not be listed, and these ci-alarm issues are open and not acked:" + fi + echo "$un" + jq -r '.[] | select(any(.labels[]; .name == "alarm-ack") | not) | "Read #\(.number), then ack it: gh issue edit \(.number) --add-label alarm-ack"' <<<"$a" + exit 4 +} +alarm_check list sync() { git fetch -q origin git worktree remove --force "$W" 2>/dev/null; git worktree add -q -B "gm/$pr" "$W" "origin/$br" || return 1 @@ -70,69 +117,22 @@ sync() { fi ) || return 1 git -C "$W" push -q origin "HEAD:refs/heads/$br" && echo "synced $br" } -# One snapshot: "<sha> <green> <terminal> <total> <non-green names>", latest run per check. -# An in-progress CheckRun reports conclusion "", a legacy StatusContext only .state. -READ='def green: IN("SUCCESS", "SKIPPED", "NEUTRAL"); - def terminal: IN("FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "STARTUP_FAILURE", "ACTION_REQUIRED", "STALE"); - .headRefOid as $sha - | [.statusCheckRollup[] - | {k: [.__typename, (.name // .context), (.workflowName // "")], t: (.startedAt // ""), n: (.name // .context), - v: ((if (.conclusion // "") != "" then .conclusion else (.state // "PENDING") end) | ascii_upcase)}] - | [group_by(.k)[] | if any(.v | (green or terminal) | not) then .[0] + {v: "PENDING"} else max_by(.t) end] as $c - | ($c | map(select(.v | green)) | length) as $g - | ($c | map(select(.v | terminal))) as $b - | "\($sha) \($g) \($b | length) \($c | length) \($b | map("\(.n)=\(.v)") | join(","))"' -# Every pull_request workflow run for a SHA, latest per workflow: "<state> <bad names>", -# state = green | pending | failed. -RUNS='[group_by(.workflowName)[] | max_by(.createdAt // "")] as $r - | ($r | map(select(.status != "completed"))) as $p - | ($r | map(select(.status == "completed" and (.conclusion | IN("success", "skipped", "neutral") | not)))) as $f - | if ($f | length) > 0 then "failed \($f | map("\(.workflowName)=\(.conclusion)") | join(","))" - elif ($p | length) > 0 then "pending -" else "green -" end' -# base_ok SHA: main is an ancestor of SHA, or main moved only in the ledger, or the PR is ledger-only. -code() { git diff --name-only "$1" "$2" | grep -qvxE 'EXPERIMENTS\.md|docs/provenance\.md'; } -base_ok() { - git fetch -q origin || return 1 - git merge-base --is-ancestor origin/main "$1" && return 0 - local mb; mb=$(git merge-base origin/main "$1") || return 1 - ! { code "$mb" origin/main && code "$mb" "$1"; } -} -# rules_stale SHA: the tree main + SHA would merge to carries a console/ledger-rules.json -# that EXPERIMENTS.md there does not generate (read with that tree's own model_opt.py). -# Unknown (a conflict, no such files) is not stale: a conflict syncs anyway. -rules_stale() { - local tree d rc=1 - tree=$(git merge-tree --write-tree origin/main "$1" 2>/dev/null | head -1) - [ -n "$tree" ] || return 1 - d=$(mktemp -d) - if git show "$tree:console/model_opt.py" > "$d/model_opt.py" 2>/dev/null \ - && git show "$tree:console/convert-pins.json" > "$d/convert-pins.json" 2>/dev/null \ - && git show "$tree:console/ledger-rules.json" > "$d/have.json" 2>/dev/null \ - && git show "$tree:EXPERIMENTS.md" > "$d/EXPERIMENTS.md" 2>/dev/null \ - && [ -s "$d/model_opt.py" ] && [ -s "$d/have.json" ] \ - && python3 "$d/model_opt.py" ledger-rules --ledger "$d/EXPERIMENTS.md" --out "$d/want.json" >/dev/null 2>&1 \ - && ! cmp -s "$d/have.json" "$d/want.json"; then - rc=0 - fi - rm -rf "$d" - return "$rc" -} [ "$(gh pr view "$pr" --json isDraft -q .isDraft)" = true ] && gh pr ready "$pr" >/dev/null merged() { [ "$(gh pr view "$pr" --json state -q .state)" = MERGED ]; } for round in 1 2 3 4 5; do merged && { echo "PR$pr MERGED"; exit 0; } [ "$(gh pr view "$pr" --json mergeable -q .mergeable)" = CONFLICTING ] && { sync || exit 1; sleep 60; } for _ in $(seq 1 "${GATE_POLLS:-60}"); do - s=$(gh pr view "$pr" --json headRefOid,statusCheckRollup -q "$READ" 2>/dev/null) || { sleep 60; continue; } - read -r sha g b n bad <<<"$s" + pj=$(gh pr view "$pr" --json headRefOid,statusCheckRollup 2>/dev/null) && [ -n "$pj" ] || { sleep 60; continue; } + sha=$(jq -r .headRefOid <<<"$pj") + read -r ci held_ok ci_last <<<"$(ci_eval "$sha" "$br")" + read -r sha g b n bad <<<"$(jq -r --argjson held_ok "${held_ok:-false}" "$READ" <<<"$pj")" [ "$b" -gt 0 ] && { echo "PR$pr FAIL at $sha: $bad"; exit 1; } if [ "$n" -gt 0 ] && [ "$g" -eq "$n" ]; then - ci=$(gh run list -w ci.yml -c "$sha" -e pull_request -L 1 --json status,conclusion \ - -q '.[0] | "\(.status)/\(.conclusion)"' 2>/dev/null) case $ci in - completed/success) + green) read -r rs rbad <<<"$(gh run list -c "$sha" -e pull_request -L 100 \ - --json workflowName,status,conclusion,createdAt -q "$RUNS" 2>/dev/null)" + --json workflowName,status,conclusion,createdAt 2>/dev/null | jq -r --argjson held_ok "$held_ok" "$RUNS" 2>/dev/null)" case $rs in failed) echo "PR$pr FAIL: workflow runs for $sha: $rbad"; exit 1;; green) ;; @@ -146,6 +146,7 @@ for round in 1 2 3 4 5; do echo "PR$pr round $round: console/ledger-rules.json would not match EXPERIMENTS.md after the merge: sync regenerates it" sync || exit 1; sleep 60; break fi + alarm_check why=$(gh pr merge "$pr" --squash --match-head-commit "$sha" --subject "$subj" --body "$body" 2>&1 >/dev/null) merged && { git push -q origin --delete "$br" 2>/dev/null; echo "PR$pr MERGED at $sha"; exit 0; } now=$(gh pr view "$pr" --json headRefOid -q .headRefOid 2>/dev/null) @@ -156,7 +157,7 @@ for round in 1 2 3 4 5; do sleep 60 fi break;; - completed/*) echo "PR$pr FAIL: ci.yml run for $sha is $ci"; exit 1;; + failed) echo "PR$pr FAIL: ci.yml for $sha did not succeed (latest $ci_last)"; exit 1;; esac # no ci.yml run for this SHA yet, or still running: wait fi sleep 60 diff --git a/hack/pin-classes b/hack/pin-classes new file mode 100755 index 00000000..3aaaa73e --- /dev/null +++ b/hack/pin-classes @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +"""pins/classes: the pin class of every path in the repo. + + hack/pin-classes class PATH the kind of the first pins/classes line that + matches PATH: ours, eat-stock, ledger or historical; + an unmatched path is ours, except a file under bench/ + or oracle/ that holds a pin token: unclassified + hack/pin-classes effective PATH as class, with a ledger line resolved against the + Hypotheses table of EXPERIMENTS.md: arm while any of + its rows is PENDING, historical once a listed row has + a verdict and none is PENDING, ours while no listed + row is in the table yet (a row being pre-registered) + hack/pin-classes excludes the glob of every ledger and historical line + hack/pin-classes coverage fails (exit 1) on a pin-bearing file under bench/ or + oracle/ that no line classifies, a historical-ref + string missing from its file, a ledger line naming a + row the Hypotheses table does not have, and a line + that matches no file + +pins/classes, one line each (# starts a comment line; first match wins; globs are +fnmatch patterns on repo-relative paths, where * also matches /): + + <glob> ours|eat-stock|historical [reason] + <glob> ledger <rows csv> amend=<aliases csv> [reason] + historical-ref <path> <exact string> + +ours k3d-gpu's own pins: always the latest stable release +eat-stock tracks Intel EAT's latest release; every change is reviewed +ledger a ledger row's runner or records. The rows are the Hypotheses table ids + that use the path; the aliases are the heading prefixes its amendments + use ("### <alias> amendment <ID>: ...") +historical a finished run's record: never edited +historical-ref an exact string in a mixed file (a finished run's pin inside a live + runner) that must keep occurring as often as it does + +A pin token (pin_tokens) is an image reference (name:tag with a registry path, +name@sha256:..., name:tag@sha256:..., or name:tag after image:, FROM or *IMAGE=), +a bare sha256:<64 hex> digest, pkg==version, a 40-hex string, or an assignment +NAME=value whose NAME ends in TAG, VERSION, REV, REVISION, COMMIT, IMAGE or DIGEST. +Comment lines and *.md files hold none. + +Python standard library only. Options: --root DIR (default: this repo), +--classes FILE (default: ROOT/pins/classes). +""" + +import argparse +import fnmatch +import importlib.util +import os +import pathlib +import re +import subprocess +import sys + +KINDS = ("ours", "eat-stock", "ledger", "historical") +GUARDED_PREFIXES = ("bench/", "oracle/") +CLASSES_FILE = "pins/classes" + +_REPO = r"[a-z0-9][a-z0-9.-]*(?::[0-9]+)?(?:/[a-z0-9][a-z0-9._-]*)+" +_TAG = r"[A-Za-z0-9_][A-Za-z0-9._-]{0,127}" +TOKEN_RES = [re.compile(p) for p in ( + # name[:tag]@sha256:<digest>, or a bare tag@sha256:<digest> (B1_TAG=v0.28.0@sha256:...) + r"(?<![\w./:@])(?:[a-z0-9][a-z0-9._/-]*(?::" + _TAG + r")?)?@sha256:[0-9a-f]{64}(?![0-9a-f])", + # a bare digest (manifest sha256:..., SHA256=...) + r"(?<![@0-9a-f])sha256:[0-9a-f]{64}(?![0-9a-f])", + # registry/path:tag, the tag not all digits (a sysfs intel-rapl:0 is no image) + r"(?<![\w./:@$-])" + _REPO + r":(?![0-9]+(?![\w.-]))" + _TAG + r"(?![\w.@/:-])", + # name:tag where an image is named (image:, FROM, *IMAGE=, ${*IMAGE:-) + r"""(?:\bimage["']?[ \t]*[:=][ \t]*["']?|^[ \t]*FROM[ \t]+|IMAGE[A-Z0-9_]*=["']?|\$\{[A-Z0-9_]*IMAGE[A-Z0-9_]*:-)""" + r"(?P<v>[a-z0-9][a-z0-9._/-]*:" + _TAG + r")(?![\w.@/:-])", + # pkg==version, the name with a lower-case letter (awk's NR==2 is no pin) + r"(?<![\w.-])(?=[A-Za-z0-9._\[\],-]*[a-z])[A-Za-z][A-Za-z0-9._\[\],-]*==[ \t]*v?[0-9][\w.+!-]*", + # a 40-hex commit + r"(?<![0-9A-Za-z])[0-9a-f]{40}(?![0-9A-Za-z])", +)] +ASSIGN_RE = re.compile(r"^[ \t]*(?:export[ \t]+)?([A-Z0-9_]*(?:TAG|VERSION|REV|REVISION|COMMIT|IMAGE|DIGEST))=(.*)$") + + +def assigned(raw): + """An assignment's value: no trailing comment or continuation, unquoted, and the + default of a ${VAR:-default} (making a pin overridable does not move it).""" + v = re.sub(r"[ \t]+#.*$", "", raw).strip().rstrip("\\").strip().strip("\"'") + m = re.match(r"^\$\{[A-Za-z_][A-Za-z0-9_]*:-(.*)\}$", v) + return m.group(1).strip("\"'") if m else v + + +def literal_value(value): + """True when an assignment's value holds a literal pin: not a command substitution, + not a variable (${X}, ${1:?...}), and some digit in it.""" + if "$(" in value or "`" in value or not re.search(r"[0-9]", value): + return False + return not value.startswith("$") + + +def pin_tokens(line): + """The pin tokens on one line of a file, in order (a list: the same token twice + counts twice). A comment line holds none.""" + if line.lstrip().startswith("#"): + return [] + spans = {} + for r in TOKEN_RES: + for m in r.finditer(line): + start, end = m.span("v" if "v" in r.groupindex else 0) + if not any(s <= start and end <= e for s, e in spans): + spans = {k: v for k, v in spans.items() if not (start <= k[0] and k[1] <= end)} + spans[(start, end)] = line[start:end] + out = [spans[k] for k in sorted(spans)] + m = ASSIGN_RE.match(line) + if m: + value = assigned(m.group(2)) + if literal_value(value): + out.insert(0, "%s=%s" % (m.group(1), value)) + return out + + +def text_tokens(path, text): + """Every pin token in a file's text; none in prose (*.md).""" + if path.endswith(".md"): + return [] + return [t for line in text.splitlines() for t in pin_tokens(line)] + + +class Line: + def __init__(self, n, glob, kind, rows=(), aliases=(), reason="", ref=None): + self.n, self.glob, self.kind, self.rows, self.aliases = n, glob, kind, tuple(rows), tuple(aliases) + self.reason, self.ref = reason, ref + + def matches(self, path): + return fnmatch.fnmatchcase(path, self.glob) + + +def parse(text, name=CLASSES_FILE): + """([Line] for glob lines in order, [Line] for historical-ref lines).""" + lines, refs = [], [] + for n, raw in enumerate(text.splitlines(), 1): + s = raw.strip() + if not s or s.startswith("#"): + continue + parts = s.split(None, 2) + if parts[0] == "historical-ref": + if len(parts) < 3 or not parts[2].strip(): + raise ValueError("%s:%d: want 'historical-ref <path> <exact string>'" % (name, n)) + refs.append(Line(n, parts[1], "historical-ref", ref=parts[2].strip())) + continue + if len(parts) < 2 or parts[1] not in KINDS: + raise ValueError("%s:%d: want '<glob> %s [...]'" % (name, n, "|".join(KINDS))) + glob, kind, rest = parts[0], parts[1], parts[2] if len(parts) > 2 else "" + if kind != "ledger": + lines.append(Line(n, glob, kind, reason=rest)) + continue + lp = rest.split(None, 2) + if len(lp) < 2 or not lp[1].startswith("amend="): + raise ValueError("%s:%d: want '<glob> ledger <rows csv> amend=<aliases csv> [reason]'" % (name, n)) + rows = [r for r in lp[0].split(",") if r] + aliases = [a for a in lp[1][len("amend="):].split(",") if a] + bad = [r for r in rows if not re.fullmatch(r"H\d+[a-z]{0,2}", r)] + if not rows or bad: + raise ValueError("%s:%d: ledger rows must be Hypotheses table ids like H54q, got %r" % (name, n, lp[0])) + lines.append(Line(n, glob, kind, rows, aliases, lp[2] if len(lp) > 2 else "")) + return lines, refs + + +def load(path): + with open(path, encoding="utf-8") as f: + return parse(f.read(), os.path.relpath(path)) + + +def match(lines, path): + """The first line whose glob matches path, or None.""" + return next((ln for ln in lines if ln.matches(path)), None) + + +def guarded(path): + return path.startswith(GUARDED_PREFIXES) + + +def class_of(lines, path, text=None): + """(kind, line): the matching line's kind; unmatched is ours, or unclassified for + a pin-bearing file under bench/ or oracle/ (text: the file's content).""" + ln = match(lines, path) + if ln: + return ln.kind, ln + if guarded(path) and text is not None and text_tokens(path, text): + return "unclassified", None + return "ours", None + + +def resolve(line, ledger): + """arm, historical or ours for a ledger line under one Hypotheses table.""" + states = [ledger.get(r) for r in line.rows] + if "PENDING" in states: + return "arm" + if any(s for s in states): + return "historical" + return "ours" + + +def effective(lines, ledger, path, text=None): + kind, ln = class_of(lines, path, text) + return (resolve(ln, ledger) if kind == "ledger" else kind), ln + + +def read_ledger(root): + """{"H1": "INCONCLUSIVE", ...}: tests/ledger_drift_check.py's ledger() over ROOT.""" + here = pathlib.Path(__file__).resolve().parent.parent + spec = importlib.util.spec_from_file_location("ledger_drift_check", here / "tests" / "ledger_drift_check.py") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod.ledger(pathlib.Path(root)) + + +def tracked(root): + try: + out = subprocess.run(["git", "-C", root, "ls-files", "-z"], capture_output=True, check=True).stdout + return sorted(p for p in out.decode().split("\0") if p) + except (OSError, subprocess.CalledProcessError): + paths = [] + for d, dirs, files in os.walk(root): + dirs[:] = sorted(x for x in dirs if x != ".git") + paths += [os.path.relpath(os.path.join(d, f), root).replace(os.sep, "/") for f in files] + return sorted(paths) + + +def read_text(root, path): + try: + with open(os.path.join(root, path), encoding="utf-8") as f: + return f.read() + except (UnicodeDecodeError, FileNotFoundError, IsADirectoryError): + return None + + +def coverage(root, lines, refs, ledger): + """Problems, one string each.""" + problems, paths = [], tracked(root) + for path in paths: + if not guarded(path) or match(lines, path): + continue + text = read_text(root, path) + if text is not None and text_tokens(path, text): + problems.append("%s: holds pin tokens (%s) and no %s line classifies it" + % (path, text_tokens(path, text)[0], CLASSES_FILE)) + for r in refs: + text = read_text(root, r.glob) + if text is None: + problems.append("%s:%d: historical-ref file %s does not exist" % (CLASSES_FILE, r.n, r.glob)) + elif r.ref not in text: + problems.append("%s:%d: historical-ref %r is missing from %s" % (CLASSES_FILE, r.n, r.ref, r.glob)) + for ln in lines: + missing = [r for r in ln.rows if r not in ledger] + if missing: + problems.append("%s:%d: %s names %s, not in the EXPERIMENTS.md Hypotheses table" + % (CLASSES_FILE, ln.n, ln.glob, ", ".join(missing))) + if not any(ln.matches(p) for p in paths): + problems.append("%s:%d: %s matches no file; drop it" % (CLASSES_FILE, ln.n, ln.glob)) + return problems + + +def main(argv): + here = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ap = argparse.ArgumentParser(prog="pin-classes", description=__doc__.split("\n")[0]) + ap.add_argument("command", choices=("class", "effective", "excludes", "coverage")) + ap.add_argument("path", nargs="?") + ap.add_argument("--root", default=here) + ap.add_argument("--classes") + a = ap.parse_args(argv[1:]) + if (a.command in ("class", "effective")) != bool(a.path): + ap.error("class and effective take one PATH; excludes and coverage take none") + try: + lines, refs = load(a.classes or os.path.join(a.root, CLASSES_FILE)) + if a.command == "excludes": + for ln in lines: + if ln.kind in ("ledger", "historical"): + print(ln.glob) + return 0 + if a.command == "coverage": + problems = coverage(a.root, lines, refs, read_ledger(a.root)) + for p in problems: + print("FAIL " + p) + if problems: + return 1 + print("ok every pin-bearing file under bench/ and oracle/ is classified in %s; " + "%d historical-ref strings present" % (CLASSES_FILE, len(refs))) + return 0 + path = os.path.normpath(a.path).replace(os.sep, "/") + text = read_text(a.root, path) + if a.command == "class": + print(class_of(lines, path, text)[0]) + else: + print(effective(lines, read_ledger(a.root), path, text)[0]) + return 0 + except (ValueError, OSError) as e: + sys.stderr.write("pin-classes: %s\n" % e) + return 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/hack/pin-guard b/hack/pin-guard new file mode 100755 index 00000000..de8646cc --- /dev/null +++ b/hack/pin-guard @@ -0,0 +1,348 @@ +#!/usr/bin/env python3 +"""Enforce pins/classes on the diff merge-base(BASE, HEAD)..HEAD. + + hack/pin-guard --base REF [--head REF] [--autobump] [--branch NAME] [--classes FILE] + + a. historical path (a historical line, or a ledger line whose rows all have a + verdict in BASE's Hypotheses table): any modification, deletion or rename fails + b. historical-ref: each string occurs in its file as often as at the merge-base + (a string the merge-base does not hold yet is being recorded, and passes) + c. arm path (a ledger line with a PENDING row in BASE's table), outside */results/, + a file the diff modifies, deletes or renames: when the pin tokens on removed + lines differ from those on added lines (as multisets), the same diff must add an + EXPERIMENTS.md heading + ### <names> amendment <ID>: <title> (YYYY-MM-DD) + ("Amendment" and "(<host>, YYYY-MM-DD)" are read the same way) + whose names include a PENDING row of the line or one of its aliases, dated no + earlier than the merge-base's commit date minus one day. A PENDING row with a + "### <row or alias> run" heading in BASE gets a warning: it has started. + A ledger line whose rows all have verdicts is an arm for this diff, not + historical, when the diff records a run of one of them (adds a "### <names> + run" heading, or rewrites its Hypotheses table line) or pre-registers a row of + it (absent from BASE's table, PENDING in HEAD's, named by an added + "### <names> pre-registration" heading, which then sets its pins; the row may + add itself to the line in HEAD's pins/classes in the same diff), or adds a + PENDING row to the line in HEAD's pins/classes with an amendment naming it (the + amendment moves that row's arm onto this runner, and is held to the rule above). + d. --autobump (an autobump/* branch): touching .github/ or pins/classes fails, and + touching EXPERIMENTS.md fails unless the branch is autobump/amend-* + e. a changed file under bench/ or oracle/ that holds a pin token and no line + classifies fails: classify it in pins/classes + +Classes come from BASE's pins/classes; a path it leaves unmatched takes HEAD's line +(a path the diff classifies). --classes FILE replaces both (a replay of commits that +predate pins/classes). Exit 0 clean, 1 on a failure, 2 on a usage or git error. +""" + +import argparse +import collections +import datetime +import importlib.machinery +import importlib.util +import os +import pathlib +import re +import subprocess +import sys +import tempfile + +HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_loader = importlib.machinery.SourceFileLoader("pin_classes", os.path.join(HERE, "hack", "pin-classes")) +_spec = importlib.util.spec_from_loader("pin_classes", _loader) +pc = importlib.util.module_from_spec(_spec) +_loader.exec_module(pc) + +AMEND_RE = re.compile(r"^### (?P<names>.+?) [Aa]mendment [A-Z]+[0-9]+: .+ \((?:[^()]*,\s*)?\d{4}-\d{2}-\d{2}\)\s*$") +PREREG_RE = re.compile(r"^###\s+(?P<names>.+?)\s+pre-registration\b") +RUN_RE = re.compile(r"^###\s+(?P<names>.+?)\s+(?:re-)?run\b") +DATE_RE = re.compile(r"(\d{4}-\d{2}-\d{2})\)\s*$") +ROW_RANGE = re.compile(r"^H(\d+)([a-z]{0,2})-H(\d+)([a-z]{0,2})$") +TABLE_ROW = re.compile(r"^\|\s*(H\d+[a-z]{0,2})\s*\|") + + +class Git: + def __init__(self, root): + self.root = root + + def run(self, *args, ok=(0,)): + p = subprocess.run(["git", "-C", self.root] + list(args), capture_output=True) + if p.returncode not in ok: + raise RuntimeError("git %s: %s" % (" ".join(args), p.stderr.decode(errors="replace").strip())) + return p + + def out(self, *args): + return self.run(*args).stdout.decode(errors="replace") + + def show(self, rev, path): + """The file at rev, or None when rev has no such (text) file.""" + p = self.run("show", "%s:%s" % (rev, path), ok=(0, 128)) + if p.returncode: + return None + try: + return p.stdout.decode("utf-8") + except UnicodeDecodeError: + return None + + +def names_of(text): + """The row ids and aliases a heading's <names> part lists, casefolded; H30-H32 is + H30, H31 and H32.""" + out = set() + for tok in re.split(r"[\s,/]+|\band\b", text): + tok = tok.strip("*`'\".:;()") + if not tok: + continue + out.add(tok.casefold()) + m = ROW_RANGE.match(tok) + if m: + a, b = int(m.group(1)), int(m.group(3)) + out.update({("H%d%s" % (a, m.group(2))).casefold(), ("H%d%s" % (b, m.group(4))).casefold()}) + if not m.group(2) and not m.group(4) and 0 <= b - a <= 50: + out.update(("H%d" % i).casefold() for i in range(a, b + 1)) + return out + + +def headings(text, rx): + return [line for line in (text or "").splitlines() if rx.match(line)] + + +def added_lines(diff_text): + """(removed, added) content lines of a -U0 diff.""" + removed, added, hunk = [], [], False + for line in diff_text.splitlines(): + if line.startswith("diff --git"): + hunk = False + elif line.startswith("@@"): + hunk = True + elif hunk and line.startswith("-"): + removed.append(line[1:]) + elif hunk and line.startswith("+"): + added.append(line[1:]) + return removed, added + + +def ledger_of(text): + if text is None: + return {} + with tempfile.TemporaryDirectory() as d: + pathlib.Path(d, "EXPERIMENTS.md").write_text(text) + return pc.read_ledger(d) + + +class Guard: + def __init__(self, root, base, head, autobump, branch, classes_file): + self.git = Git(root) + self.base = self.git.out("rev-parse", "--verify", base + "^{commit}").strip() + self.head = self.git.out("rev-parse", "--verify", head + "^{commit}").strip() + self.mb = self.git.out("merge-base", self.base, self.head).strip() + self.autobump, self.branch = autobump, branch + self.fails, self.warns = [], [] + if classes_file: + lines, refs = pc.load(classes_file) + self.base_lines, self.head_lines, self.refs = lines, [], refs + else: + base_text = self.git.show(self.base, pc.CLASSES_FILE) + head_text = self.git.show(self.head, pc.CLASSES_FILE) + self.base_lines, base_refs = pc.parse(base_text) if base_text else ([], []) + self.head_lines, head_refs = pc.parse(head_text) if head_text else ([], []) + seen = {(r.glob, r.ref) for r in base_refs} + self.refs = base_refs + [r for r in head_refs if (r.glob, r.ref) not in seen] + base_exp = self.git.show(self.base, "EXPERIMENTS.md") + self.ledger = ledger_of(base_exp) + self.head_ledger = ledger_of(self.git.show(self.head, "EXPERIMENTS.md")) + self.run_names = [names_of(RUN_RE.match(h).group("names")) for h in headings(base_exp, RUN_RE)] + exp_removed, exp_added = added_lines(self.diff("EXPERIMENTS.md")) + self.amendments = [] + for line in exp_added: + m = AMEND_RE.match(line) + if m: + d = DATE_RE.search(line) + self.amendments.append((line, names_of(m.group("names")), d.group(1) if d else None)) + self.preregs = set() + self.landing = set() # rows whose run this diff records + for line in exp_added: + m = PREREG_RE.match(line) + self.preregs |= names_of(m.group("names")) if m else set() + m = RUN_RE.match(line) + self.landing |= names_of(m.group("names")) if m else set() + # A Hypotheses table line the diff rewrites: a verdict landing, or a re-opened row's. + rewritten = {m.group(1) for m in map(TABLE_ROW.match, exp_removed) if m} + self.landing |= {m.group(1).casefold() for m in map(TABLE_ROW.match, exp_added) if m and m.group(1) in rewritten} + day = self.git.out("log", "-1", "--format=%cs", self.mb).strip() + self.floor = (datetime.date.fromisoformat(day) - datetime.timedelta(days=1)).isoformat() + + def diff(self, *paths): + return self.git.out("diff", "--no-ext-diff", "--no-color", "-U0", "-M", self.mb, self.head, "--", *paths) + + def line_for(self, path): + ln = pc.match(self.base_lines, path) + return ln if ln else pc.match(self.head_lines, path) + + def effective(self, path): + ln = self.line_for(path) + if ln is None: + return "ours", None + if ln.kind != "ledger": + return ln.kind, ln + state = pc.resolve(ln, self.ledger) + if state == "historical" and (self.prereg_rows(ln, path) or self.landing_rows(ln) + or self.joined_rows(ln, path)): + # reused by a row this diff pre-registers or amends onto it, or a re-run it records + state = "arm" + return state, ln + + def landing_rows(self, ln): + """Rows of the line whose run record this diff adds ("### <names> run ...").""" + return [r for r in ln.rows if r.casefold() in self.landing] + + def prereg_rows(self, ln, path): + """Rows of the line this diff pre-registers: absent from BASE's table, PENDING in + HEAD's, named by an added pre-registration heading. HEAD's ledger line for path + counts too, so a new row can add itself to a finished runner's line in the same + diff (no other change to BASE's line takes effect).""" + head = pc.match(self.head_lines, path) + rows, aliases = list(ln.rows), list(ln.aliases) + if head is not None and head.kind == "ledger": + rows += [r for r in head.rows if r not in rows] + aliases += [a for a in head.aliases if a not in aliases] + new = [r for r in rows if r not in self.ledger and self.head_ledger.get(r) == "PENDING"] + return [r for r in new if r.casefold() in self.preregs or any(a.casefold() in self.preregs for a in aliases)] + + def joined_rows(self, ln, path): + """PENDING rows (in BASE's table) that HEAD's ledger line for path adds to BASE's + line, each named by an amendment heading this diff adds: the amendment moves the + row's arm onto this runner. check_arm then holds the amendment to its date floor.""" + head = pc.match(self.head_lines, path) + if head is None or head.kind != "ledger": + return [] + named = set().union(*(a[1] for a in self.amendments)) + return [r for r in head.rows if r not in ln.rows and self.ledger.get(r) == "PENDING" + and r.casefold() in named] + + def fail(self, msg): + self.fails.append(msg) + + def changes(self): + """[(status letter, [path] or [old path, new path])] of the diff.""" + fields = self.git.out("diff", "--name-status", "-z", "-M", self.mb, self.head).split("\0") + out, i = [], 0 + while i < len(fields) and fields[i]: + code = fields[i][0] + n = 2 if code in "RC" else 1 + out.append((code, fields[i + 1:i + 1 + n])) + i += 1 + n + return out + + def check(self): + changes = self.changes() + for code, paths in changes: + old, new = paths[0], paths[-1] + for p in set(paths): + self.check_autobump(p) + if code in "MDRT": + kind, ln = self.effective(old) + if kind == "historical": + what = {"M": "modified", "D": "deleted", "R": "renamed to " + new, "T": "changed type"}[code] + why = ("%s line %d" % (pc.CLASSES_FILE, ln.n)) if ln else pc.CLASSES_FILE + self.fail("%s: historical record %s (%s: %s)" % (old, what, why, self.state_of(ln))) + continue + target = old if code == "D" else new + kind, ln = self.effective(target) + # A new file moves no pin a row already has; its pins are guarded from then on. + if kind == "arm" and code not in "AC" and "/results/" not in "/" + target: + self.check_arm(code, old, new, target, ln) + if code != "D" and pc.guarded(new) and self.line_for(new) is None: + text = self.git.show(self.head, new) + if text is not None and pc.text_tokens(new, text): + self.fail("%s: holds pin tokens (%s) and no %s line classifies it: classify it in %s" + % (new, pc.text_tokens(new, text)[0], pc.CLASSES_FILE, pc.CLASSES_FILE)) + self.check_refs({p for _, paths in changes for p in paths}) + return self.fails, self.warns + + def state_of(self, ln): + if ln is None or ln.kind != "ledger": + return "historical" + (" (%s)" % ln.reason if ln and ln.reason else "") + return "ledger rows %s have verdicts in %s's EXPERIMENTS.md" % (",".join(ln.rows), self.base[:12]) + + def check_autobump(self, path): + if not self.autobump: + return + if path.startswith(".github/"): + self.fail("%s: an autobump branch never touches .github/" % path) + elif path == pc.CLASSES_FILE: + self.fail("%s: an autobump branch never reclassifies pins" % path) + elif path == "EXPERIMENTS.md" and not (self.branch or "").startswith("autobump/amend-"): + self.fail("EXPERIMENTS.md: only an autobump/amend-* branch touches the ledger (branch %s)" % (self.branch or "?")) + + def check_arm(self, code, old, new, target, ln): + if target.endswith(".md"): + return + removed, added = added_lines(self.diff(*sorted({old, new}))) + gone = collections.Counter(t for line in removed for t in pc.pin_tokens(line)) + came = collections.Counter(t for line in added for t in pc.pin_tokens(line)) + if gone == came: + return + pending = [r for r in ln.rows if self.ledger.get(r) == "PENDING"] + for r in pending: + if any(r.casefold() in n for n in self.run_names) or any( + a.casefold() in n for n in self.run_names for a in ln.aliases if a not in self.ledger): + self.warns.append("%s: %s has a run heading in EXPERIMENTS.md already: this pin change " + "amends a row that has started" % (target, r)) + break + if not pending and self.prereg_rows(ln, target): + return # the pre-registration this diff adds sets the new row's pins + rows = pending or self.landing_rows(ln) or self.joined_rows(ln, target) + owners = {r.casefold() for r in rows} | {a.casefold() for a in ln.aliases} + if [a for a in self.amendments if a[1] & owners and a[2] and a[2] >= self.floor]: + return + stale = [a for a in self.amendments if a[1] & owners and (not a[2] or a[2] < self.floor)] + changed = sorted((gone - came).elements())[:2] + sorted((came - gone).elements())[:2] + if stale: + self.fail("%s: arm pin change (%s); the amendment %r is dated before %s" + % (target, ", ".join(changed), stale[0][0], self.floor)) + else: + self.fail("%s: arm pin change (%s) for %s %s with no amendment in this diff: add " + "'### <%s> amendment <ID>: <title> (YYYY-MM-DD)' to EXPERIMENTS.md" + % (target, ", ".join(changed), "PENDING" if pending else "re-run", ",".join(rows), + "|".join(rows + list(ln.aliases)))) + + def check_refs(self, changed): + for r in self.refs: + if r.glob not in changed: + continue + before = (self.git.show(self.mb, r.glob) or "").count(r.ref) + after = (self.git.show(self.head, r.glob) or "").count(r.ref) + if before and after != before: + self.fail("%s: historical-ref %r occurs %d time(s), %d at the merge-base (%s line %d)" + % (r.glob, r.ref, after, before, pc.CLASSES_FILE, r.n)) + + +def main(argv): + ap = argparse.ArgumentParser(prog="pin-guard", description=__doc__.split("\n")[0]) + ap.add_argument("--base", required=True) + ap.add_argument("--head", default="HEAD") + ap.add_argument("--autobump", action="store_true") + ap.add_argument("--branch", default=os.environ.get("GITHUB_HEAD_REF") or os.environ.get("GITHUB_REF_NAME")) + ap.add_argument("--classes") + ap.add_argument("--root", default=os.getcwd()) + a = ap.parse_args(argv[1:]) + try: + g = Guard(a.root, a.base, a.head, a.autobump, a.branch, a.classes) + fails, warns = g.check() + except (RuntimeError, ValueError, OSError) as e: + sys.stderr.write("pin-guard: %s\n" % e) + return 2 + for w in warns: + print("::warning::" + w) + for f in fails: + print("FAIL " + f) + if os.environ.get("GITHUB_ACTIONS"): + print("::error::" + f) + if fails: + return 1 + print("ok %s..%s: pins/classes holds (%d warning(s))" % (g.mb[:12], g.head[:12], len(warns))) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/hack/release-paths b/hack/release-paths new file mode 100644 index 00000000..a3bc94e3 --- /dev/null +++ b/hack/release-paths @@ -0,0 +1,29 @@ +# The paths whose change ships to users: a commit on main that touches one of them +# (and carries no [skip release]) cuts a launcher release. One entry per line: a +# file, or dir/** for everything under dir. # starts a comment. +# +# Read by hack/release_cas.sh (its git log path filter for push runs and +# mode=changed dispatches). .github/workflows/auto-release.yml repeats the list as +# its literal push paths: (GitHub reads no file there); tests/ci_wiring_test.sh +# checks the two are equal, and that this list covers every file +# packaging/nfpm/nfpm.yaml and Formula/k3d-gpu.rb install (README.md and +# LICENSE.md excepted: documentation rides the next release). +packaging/** +scripts/** +share/** +Dockerfile +console/** +images.lock +bench/vs.py +bench/llm_load.py +bench/energy.sh +bench/agent_trace.py +bench/agent_trace_gen.py +bench/apb/apb.py +bench/apb/capacity.py +bench/competitors.lock +oracle/bundle.py +oracle/project.py +oracle/validate.py +oracle/share.py +oracle/schema/bundle-v1.json diff --git a/hack/release-tags b/hack/release-tags new file mode 100755 index 00000000..4be75439 --- /dev/null +++ b/hack/release-tags @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# release-tags: the image tags a release build pushes, for build-and-push.yml. +# +# hack/release-tags <version X.Y.Z> <k3s tag vX.Y.Z-k3sN> +# +# Prints one line per tag: +# <variant> versioned <ref> pushed by the build of every release +# <variant> floating <ref> <source> copied from <source> after both builds +# variant is 26.04 (the default image) or 24.04. Versioned: <repo>:<ver>, +# <repo>:<ver>-ubuntu26.04, <repo>:<ver>-ubuntu24.04. Floating: <repo>:latest, +# <repo>:ubuntu26.04, <repo>:<k3s>-ubuntu26.04, <repo>:ubuntu24.04, +# <repo>:<k3s>-ubuntu24.04. Floating tags are printed only when <version> is the +# highest aur-v* tag on $REMOTE now (git ls-remote, sort -V), so an older release +# that builds late never moves them back. An unreadable remote is an error. +# +# Env: RELEASE_REPOS (default "cryptoandcoffee/k3d-gpu ghcr.io/88plug/k3d-gpu"), +# REMOTE (default origin). +set -euo pipefail + +[ $# -eq 2 ] || { echo "usage: hack/release-tags <version> <k3s tag>" >&2; exit 2; } +VER=$1 K3S=$2 +[[ "$VER" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "release-tags: version '$VER' is not X.Y.Z" >&2; exit 2; } +[[ "$K3S" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-k3s[0-9]+$ ]] || { echo "release-tags: k3s tag '$K3S' is not vX.Y.Z-k3sN" >&2; exit 2; } +read -r -a REPOS <<< "${RELEASE_REPOS:-cryptoandcoffee/k3d-gpu ghcr.io/88plug/k3d-gpu}" + +refs=$(git ls-remote --tags "${REMOTE:-origin}" 'aur-v*') +highest=$(sed -n 's|^[0-9a-f]*[[:space:]]*refs/tags/aur-v\([0-9]*\.[0-9]*\.[0-9]*\)$|\1|p' <<< "$refs" | sort -V | tail -n 1) + +for r in "${REPOS[@]}"; do + echo "26.04 versioned $r:$VER" + echo "26.04 versioned $r:$VER-ubuntu26.04" + echo "24.04 versioned $r:$VER-ubuntu24.04" +done +[ "$VER" = "$highest" ] || exit 0 +for r in "${REPOS[@]}"; do + for t in latest ubuntu26.04 "$K3S-ubuntu26.04"; do echo "26.04 floating $r:$t $r:$VER"; done + for t in ubuntu24.04 "$K3S-ubuntu24.04"; do echo "24.04 floating $r:$t $r:$VER-ubuntu24.04"; done +done diff --git a/hack/release_cas.sh b/hack/release_cas.sh index 97b4c1f6..261ca4f8 100755 --- a/hack/release_cas.sh +++ b/hack/release_cas.sh @@ -18,9 +18,12 @@ # release commit whose tag did not land), else one patch above the last tag. # # EVENT=push releases when main's VERSION is above the last tag, or when a -# commit above the last tag touches packaging/, scripts/, share/ or Dockerfile -# and carries no [skip release] in its message; otherwise it exits 0 with no -# tag. Any other EVENT (workflow_dispatch) always releases. +# commit above the last tag touches a path main's hack/release-paths lists (what +# nfpm, the AUR package, the formula and the image ship) and carries no +# [skip release] in its message; otherwise it prints "nothing to release" and +# exits 0 with no tag. auto-release.yml passes EVENT=push for pushes and for +# mode=changed dispatches. Any other EVENT (a forced workflow_dispatch) always +# releases. # # Env: EVENT, REMOTE (default origin), RELEASE_CAS_ATTEMPTS, GITHUB_OUTPUT # (receives tag= and ver= when a release is cut). @@ -43,8 +46,12 @@ for attempt in $(seq 1 "${RELEASE_CAS_ATTEMPTS:-5}"); do VER=0.1.0 else if [ "$EVENT" = push ]; then + # hack/release-paths entries: a file, or dir/** (the pathspec dir). + [ -f hack/release-paths ] || { echo "no hack/release-paths on $REMOTE/main"; exit 1; } + mapfile -t paths < <(sed -e 's/#.*//' -e 's/[[:space:]]*$//' -e '/^$/d' -e 's|/\*\*$||' hack/release-paths) + [ "${#paths[@]}" -gt 0 ] || { echo "hack/release-paths lists no path"; exit 1; } n=$(git log -F --invert-grep --grep='[skip release]' --format=%H "$LATEST..HEAD" \ - -- packaging scripts share Dockerfile | wc -l) + -- "${paths[@]}" | wc -l) if [ "$n" -eq 0 ]; then echo "nothing to release above $LATEST"; exit 0; fi fi VER=${LAST%.*}.$(( ${LAST##*.} + 1 )) diff --git a/hack/watchdog b/hack/watchdog new file mode 100755 index 00000000..d1989b3d --- /dev/null +++ b/hack/watchdog @@ -0,0 +1,699 @@ +#!/usr/bin/env python3 +"""watchdog: notice red, missing or stuck automation within hours, not days. + +Each run (.github/workflows/watchdog.yml: hourly, and after every listed workflow +completes on main) checks the repository and opens or closes one hack/ci_alarm +issue per finding: + + a. scheduled workflows Every .github/workflows/*.yml with a cron. stale:<file> + when its last successful scheduled run is older than the + cron period + 6 h (none at all counts, unless the workflow + was added within that time). red:<file> when the latest + completed scheduled run concluded failure, startup_failure + or timed_out: the run, its failed jobs, the last 60 lines + of its failed log up to the last ##[error], and the hint of + the first matching hack/ci_alarm_signatures.tsv line. Once + per failed run id (kept in the alarm body) the workflow is + re-dispatched on main when it has workflow_dispatch. A + green latest scheduled run closes red:<file>. + b. workflow state A workflow file whose Actions state is not active: + disabled:<file>. + c. main CI main's HEAD older than 30 min with no ci.yml run (push or + workflow_dispatch) for its SHA: dispatch ci.yml on main + (the dispatched run is for that SHA, so it happens once); + still none 2 h after HEAD: ci-missing. The latest completed + ci.yml run on main red: ci-main; green closes it. + d. release integrity The newest aur-v* tag older than 45 min (tag time = its + commit's committer date in --root's git) must have + cryptoandcoffee/k3d-gpu:<ver> on Docker Hub, + ghcr.io/<owner>/k3d-gpu:<ver>, the GitHub release + aur-v<ver> with its .deb, .rpm and SHA256SUMS, and AUR at + <ver> or later. A missing Docker Hub image dispatches + build-and-push.yml -f release=<ver> once (kept in the alarm + body). Any gap: release:<ver>, listing older tags from the + last 14 days with gaps as information. Other release:* + alarms close as superseded. Skipped while a release + workflow is queued or running. + e. stuck bot PRs Open autobump/* PRs from this repository (not a fork's) + older than 72 h whose head SHA has no successful ci.yml + run: bot-pr-stuck. + f. simulate_red SIMULATE_RED=true opens simulate; a run without it closes it. + +A re-dispatch (a and d) is recorded in its alarm before it runs, and the record is +what stops the next one. A run that cannot read or file the alarm (issues +disabled, the open alarms could not be listed, the alarm write failed) therefore +dispatches nothing; a dispatch that fails is not recorded, and the next run tries +again. Recording after dispatching let every watchdog run that could not file +alarms re-dispatch the red workflows, watchdog.yml among them, without end. + +A failed gh, git, registry or AUR call is collected and filed as watchdog-error +with its text (the other checks still run); a run with no error closes it. Exit 0 +unless an alarm could not be filed (the repository has issues disabled, or +ci_alarm failed for watchdog-error itself): then ::error annotations and exit 1. + +Usage: hack/watchdog [--root DIR] [--list-scheduled] + --root DIR the checkout to read workflows and aur-v* tags from (default: + this repo). The workflow needs fetch-depth: 0 for the tags. + --list-scheduled print "<file> <cron period in hours>" per scheduled workflow + +Env: GH_REPO or GITHUB_REPOSITORY (owner/repo), SIMULATE_RED, ALARM_OWNER (for +ci_alarm), GITHUB_STEP_SUMMARY; for tests: WATCHDOG_NOW (epoch seconds), +WATCHDOG_ALARM (ci_alarm path), WATCHDOG_IMAGES_LOCK (the file registry_digest is +loaded from; default scripts/images-lock), WATCHDOG_AUR_URL. + +Python standard library; gh on PATH. +""" + +import argparse +import datetime +import importlib.machinery +import importlib.util +import json +import os +import re +import subprocess +import sys +import tempfile +import traceback +import urllib.error +import urllib.request + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO_ROOT = os.path.dirname(HERE) +MINUTE, HOUR, DAY = 60, 3600, 86400 +RED = ("failure", "startup_failure", "timed_out") +DECIDED = RED + ("success",) +RELEASE_WORKFLOWS = ("Auto-update, Build & Release", "Auto-release AUR", + "Release packages (deb, rpm, Homebrew)", "AUR Deploy") +HUB_REPO = "cryptoandcoffee/k3d-gpu" +AUR_URL = "https://aur.archlinux.org/rpc/v5/info?arg[]=k3d-gpu" +TAG_RE = re.compile(r"^aur-v(\d+)\.(\d+)\.(\d+)$") +ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]") +CRON_LINE_RE = re.compile(r"""^\s*-\s*cron:\s*(['"]?)([^'"#\n]+?)\1\s*(?:#.*)?$""", re.M) +DISPATCH_RE = re.compile(r"^\s+workflow_dispatch:|^on:.*\bworkflow_dispatch\b", re.M) +LOG_LINES, LOG_WIDTH = 60, 300 + + +class ToolError(Exception): + """A gh, git, registry or AUR call failed.""" + + +def ts(s): + return datetime.datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp() + + +def iso(t): + return datetime.datetime.fromtimestamp(t, datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def hours(seconds): + return "%g" % round(seconds / HOUR, 2) + + +def vkey(ver): + return tuple(int(x) for x in ver.split(".")) + + +# ── cron ───────────────────────────────────────────────────────────────────── + +def cron_values(field, lo, hi): + """The set a cron field allows: *, n, a-b, with /step, comma lists.""" + out = set() + for part in field.split(","): + step = 1 + if "/" in part: + part, s = part.split("/", 1) + step = int(s) + if part == "*": + a, b = lo, hi + elif "-" in part: + a, b = (int(x) for x in part.split("-", 1)) + else: + a = int(part) + b = hi if step > 1 else a + if a < lo or b > hi or a > b or step < 1: + raise ValueError("cron field %r out of range %d-%d" % (field, lo, hi)) + out.update(range(a, b + 1, step)) + return out + + +def cron_period(crons): + """The longest gap, in seconds, between consecutive firings of the crons (UTC).""" + specs = [] + for c in crons: + f = c.split() + if len(f) != 5: + raise ValueError("cron %r: want 5 fields" % c) + specs.append((cron_values(f[0], 0, 59), cron_values(f[1], 0, 23), cron_values(f[2], 1, 31), + cron_values(f[3], 1, 12), {d % 7 for d in cron_values(f[4], 0, 7)}, + f[2] != "*", f[4] != "*")) + start = datetime.datetime(2026, 1, 5, tzinfo=datetime.timezone.utc) # a Monday + fires = [] + for day in range(800): + d = start + datetime.timedelta(days=day) + dow = (d.weekday() + 1) % 7 # cron: Sunday is 0 + for mins, hrs, doms, mons, dows, dom_set, dow_set in specs: + if d.month not in mons: + continue + dom_ok, dow_ok = d.day in doms, dow in dows + if not ((dom_ok or dow_ok) if (dom_set and dow_set) else (dom_ok and dow_ok)): + continue + base = d.timestamp() + fires.extend(base + h * HOUR + m * MINUTE for h in hrs for m in mins) + fires = sorted(set(fires)) + if len(fires) < 2: + return 366 * DAY + return max(b - a for a, b in zip(fires, fires[1:])) + + +def scheduled_workflows(root): + d = os.path.join(root, ".github", "workflows") + out = [] + for f in sorted(os.listdir(d)): + if not f.endswith((".yml", ".yaml")): + continue + with open(os.path.join(d, f)) as fh: + text = fh.read() + crons = [m.group(2).strip() for m in CRON_LINE_RE.finditer(text)] + if crons: + out.append({"file": f, "crons": crons, "period": cron_period(crons), + "dispatch": bool(DISPATCH_RE.search(text))}) + return out + + +def has_dispatch(root, f): + try: + with open(os.path.join(root, ".github", "workflows", f)) as fh: + return bool(DISPATCH_RE.search(fh.read())) + except FileNotFoundError: + return False + + +# ── tools ──────────────────────────────────────────────────────────────────── + +class Gh: + def __init__(self, repo): + self.repo = repo + + def run(self, *args): + p = subprocess.run(["gh", *args], capture_output=True, text=True) + if p.returncode != 0: + raise ToolError("gh %s: exit %d: %s" % (" ".join(args), p.returncode, p.stderr.strip()[-800:])) + return p.stdout + + def json(self, *args): + out = self.run(*args) + try: + return json.loads(out) if out.strip() else None + except ValueError: + raise ToolError("gh %s: not JSON: %s" % (" ".join(args), out[:200])) from None + + +def load_registry_digest(): + path = os.environ.get("WATCHDOG_IMAGES_LOCK") or os.path.join(REPO_ROOT, "scripts", "images-lock") + loader = importlib.machinery.SourceFileLoader("watchdog_images_lock", path) + spec = importlib.util.spec_from_loader("watchdog_images_lock", loader) + mod = importlib.util.module_from_spec(spec) + loader.exec_module(mod) + return mod.registry_digest + + +def marker(body, name): + m = re.search(r"<!-- watchdog:%s=(\S+) -->" % re.escape(name), body or "") + return m.group(1) if m else None + + +class Alarms: + """hack/ci_alarm, plus the open alarms (key -> issue body) read once per run.""" + + def __init__(self, gh, script): + self.script = script + self.open = None # None: unknown (the listing failed) + self.disabled = False + self.undelivered = [] + self.opened, self.closed = [], [] + try: + issues = gh.json("issue", "list", "--label", "ci-alarm", "--state", "open", "--limit", "200", + "--json", "number,title,body") or [] + self.open = {} + for it in issues: + m = re.match(r"^\[ci-alarm\] (\S+): ", it["title"]) + if m: + self.open.setdefault(m.group(1), it.get("body") or "") + except ToolError as e: + if "disabled issues" in str(e).lower(): + self.disabled = True + self.open = {} + self.list_error = e + else: + self.list_error = None + + def body(self, key): + return (self.open or {}).get(key) + + def keys(self, prefix): + return [k for k in (self.open or {}) if k.startswith(prefix)] + + def _call(self, *args): + p = subprocess.run([self.script, *args], capture_output=True, text=True) + sys.stdout.write(p.stdout) + sys.stderr.write(p.stderr) + return p + + def raise_(self, key, title, body): + with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False) as f: + f.write(body.rstrip() + "\n") + try: + p = self._call("open", key, title, f.name) + finally: + os.unlink(f.name) + if p.returncode == 3: + if key not in self.undelivered: + self.undelivered.append(key) + return False + if p.returncode != 0: + raise ToolError("ci_alarm open %s: exit %d: %s" % (key, p.returncode, p.stderr.strip()[-400:])) + print("::warning title=ci-alarm %s::%s" % (key, title)) + if (key, title) not in self.opened: + self.opened.append((key, title)) + return True + + def clear(self, key, comment): + if self.disabled or (self.open is not None and key not in self.open): + return + p = self._call("close", key, comment) + if p.returncode not in (0, 3): + raise ToolError("ci_alarm close %s: exit %d: %s" % (key, p.returncode, p.stderr.strip()[-400:])) + if self.open and key in self.open: + self.closed.append(key) + + +class Watchdog: + def __init__(self, root, repo, now): + self.root, self.repo, self.now = root, repo, now + self.gh = Gh(repo) + self.errors = [] + self.alarms = Alarms(self.gh, os.environ.get("WATCHDOG_ALARM") or os.path.join(HERE, "ci_alarm")) + if self.alarms.list_error: + self.errors.append("listing open alarms: %s" % self.alarms.list_error) + self._registry = None + self.signatures = [] + with open(os.path.join(HERE, "ci_alarm_signatures.tsv")) as f: + for ln in f: + if ln.strip() and not ln.startswith("#"): + rx, hint = ln.rstrip("\n").split("\t", 1) + self.signatures.append((re.compile(rx), hint)) + + def guard(self, name, fn, *args): + try: + return fn(*args) + except ToolError as e: + self.errors.append("%s: %s" % (name, e)) + except Exception: # a bug in a check must not silence the others + self.errors.append("%s: %s" % (name, traceback.format_exc(limit=3).strip())) + return None + + # ── one dispatch per token ─────────────────────────────────────────── + + def raise_dispatching(self, key, title, body, token, argv, done_text): + """Raise alarm KEY and run `gh ARGV` once per TOKEN. The record of the + dispatch (<!-- watchdog:dispatched=TOKEN -->) is written into the alarm + before the dispatch runs, so a run that cannot read or write the alarm + dispatches nothing: + - issues disabled: the alarm is annotated only, nothing is dispatched; + - the open alarms could not be listed: the alarm is left as it is (its + record stays), nothing is dispatched; watchdog-error says why; + - the alarm write fails: nothing is dispatched (the error is collected); + - the dispatch fails: the alarm is rewritten without the record and + says so, and the next run tries again.""" + claimed = "\n".join(body + ["", done_text, "<!-- watchdog:dispatched=%s -->" % token]) + if self.alarms.disabled: + self.alarms.raise_(key, title, "\n".join(body)) + return + if self.alarms.open is None: + return + if marker(self.alarms.body(key), "dispatched") == token: + self.alarms.raise_(key, title, claimed) + return + if not self.alarms.raise_(key, title, claimed): + return + try: + self.gh.run(*argv) + print("dispatched: gh %s" % " ".join(argv)) + except ToolError as e: + self.errors.append("dispatching for %s: %s" % (key, e)) + self.alarms.raise_(key, title, "\n".join(body + ["", "Re-dispatch failed: %s" % e])) + + # ── failed-run description ─────────────────────────────────────────── + + def describe_failed_run(self, run): + rid = str(run["databaseId"]) + jobs = (self.gh.json("run", "view", rid, "-R", self.repo, "--json", "jobs") or {}).get("jobs", []) + failed = [j["name"] for j in jobs if j.get("conclusion") in ("failure", "timed_out")] + lines = ["Run: %s" % run.get("url", rid), + "Conclusion: **%s**, created %s, commit %s" % (run.get("conclusion"), run.get("createdAt", "?"), + run.get("headSha", "?")), + "Failed jobs: %s" % (", ".join(failed) or "(none reported)")] + try: + log = ANSI_RE.sub("", self.gh.run("run", "view", rid, "-R", self.repo, "--log-failed")) + except ToolError as e: + lines.append("Failed log unavailable: %s" % str(e)[-300:]) + return lines + rows = log.splitlines() + hint = next((h for rx, h in self.signatures if any(rx.search(r) for r in rows)), None) + lines.append("Hint: %s" % (hint or "no known signature matched")) + errs = [i for i, r in enumerate(rows) if "##[error]" in r] + end = errs[-1] + 1 if errs else len(rows) + tail = [r.replace("\t", " ")[:LOG_WIDTH].replace("```", "'''") for r in rows[max(0, end - LOG_LINES):end]] + lines += ["", "<details><summary>Last %d lines of the failed log</summary>" % len(tail), "", + "```text", *tail, "```", "</details>"] + return lines + + # ── a + b ──────────────────────────────────────────────────────────── + + def check_workflow_state(self): + doc = self.gh.json("api", "repos/%s/actions/workflows?per_page=100" % self.repo) or {} + meta = {} + for w in doc.get("workflows", []): + path = w.get("path", "") + if not path.startswith(".github/workflows/"): + continue + f = path.rsplit("/", 1)[1] + meta[f] = w + if not os.path.exists(os.path.join(self.root, path)): + continue + key = "disabled:" + f + if w.get("state") != "active": + self.alarms.raise_(key, "%s is %s" % (f, w.get("state")), "\n".join([ + "Workflow `%s` (%s) has Actions state `%s`, so it runs no more." % (f, w.get("name"), w.get("state")), + "", "Enable it: Actions, %s, Enable workflow (or `gh workflow enable %s`)." % (w.get("name"), f)])) + else: + self.alarms.clear(key, "%s is active again." % f) + return meta + + def check_scheduled(self, wf, meta): + f = wf["file"] + fields = "databaseId,conclusion,status,createdAt,url,headSha,workflowName" + runs = self.gh.json("run", "list", "-R", self.repo, "--workflow", f, "--event", "schedule", + "--limit", "30", "--json", fields) or [] + success = next((r for r in runs if r.get("conclusion") == "success"), None) + if success is None: + more = self.gh.json("run", "list", "-R", self.repo, "--workflow", f, "--event", "schedule", + "--status", "success", "--limit", "1", "--json", fields) or [] + success = more[0] if more else None + threshold = wf["period"] + 6 * HOUR + created = (meta or {}).get(f, {}).get("created_at") + fresh = success is not None and self.now - ts(success["createdAt"]) <= threshold + new = success is None and created is not None and self.now - ts(created) <= threshold + key = "stale:" + f + if fresh or new: + self.alarms.clear(key, "A scheduled run of %s succeeded within %s h." % (f, hours(threshold))) + else: + last = ("%s at %s" % (success["url"], success["createdAt"])) if success else "none found" + self.alarms.raise_(key, "no successful scheduled run within %s h" % hours(threshold), "\n".join([ + "`%s` (cron %s, every %s h) has no successful scheduled run within %s h (period + 6 h)." + % (f, ", ".join("`%s`" % c for c in wf["crons"]), hours(wf["period"]), hours(threshold)), + "", "Last successful scheduled run: %s" % last, + "", "A scheduled run that never starts leaves no red run: check the workflow is enabled " + "and that its last runs were not skipped."])) + + latest = next((r for r in runs if r.get("status") == "completed" and r.get("conclusion") in DECIDED), None) + key = "red:" + f + if latest is None: + return + if latest["conclusion"] == "success": + self.alarms.clear(key, "The latest scheduled run is green: %s" % latest.get("url")) + return + rid = str(latest["databaseId"]) + body = ["The latest scheduled run of `%s` failed." % f, ""] + self.describe_failed_run(latest) + title = "scheduled run %s" % latest["conclusion"] + if not wf["dispatch"]: + self.alarms.raise_(key, title, "\n".join(body)) + return + self.raise_dispatching(key, title, body, rid, ("workflow", "run", f, "-R", self.repo, "--ref", "main"), + "Re-dispatched `%s` on main once after this run; a further re-dispatch " + "happens only after a new failed scheduled run." % f) + + # ── c ──────────────────────────────────────────────────────────────── + + def check_main_ci(self): + head = self.gh.json("api", "repos/%s/commits/main" % self.repo) or {} + sha = head["sha"] + age = self.now - ts(head["commit"]["committer"]["date"]) + runs = self.gh.json("run", "list", "-R", self.repo, "--workflow", "ci.yml", "--commit", sha, + "--limit", "20", "--json", "databaseId,event,status,conclusion") or [] + if any(r.get("event") in ("push", "workflow_dispatch") for r in runs): + self.alarms.clear("ci-missing", "ci.yml runs for main's HEAD %s." % sha) + elif age >= 2 * HOUR: + self.alarms.raise_("ci-missing", "main's HEAD has no ci.yml run", "\n".join([ + "main's HEAD `%s` (committed %s) has no ci.yml run from a push or workflow_dispatch, " + "2 h after the commit." % (sha, head["commit"]["committer"]["date"]), + "", "A push made with GITHUB_TOKEN starts no workflow; the watchdog dispatched ci.yml on main " + "once when HEAD was 30 min old. Dispatch it: `gh workflow run ci.yml --ref main`."])) + else: + self.alarms.clear("ci-missing", "main moved to %s." % sha) + if age >= 30 * MINUTE and has_dispatch(self.root, "ci.yml"): + self.gh.run("workflow", "run", "ci.yml", "-R", self.repo, "--ref", "main") + print("dispatched ci.yml on main for HEAD %s (no ci.yml run %s min after the commit)" + % (sha, int(age // MINUTE))) + + runs = self.gh.json("run", "list", "-R", self.repo, "--workflow", "ci.yml", "--branch", "main", + "--limit", "20", "--json", "databaseId,event,status,conclusion,url,headSha,createdAt") or [] + latest = next((r for r in runs if r.get("status") == "completed" + and r.get("event") in ("push", "workflow_dispatch") + and r.get("conclusion") in DECIDED), None) + if latest is None: + return + if latest["conclusion"] == "success": + self.alarms.clear("ci-main", "ci.yml on main is green: %s" % latest.get("url")) + else: + self.alarms.raise_("ci-main", "ci.yml on main %s" % latest["conclusion"], + "\n".join(["The latest completed ci.yml run on main failed.", ""] + + self.describe_failed_run(latest))) + + # ── d ──────────────────────────────────────────────────────────────── + + def registry_status(self, name, tag): + if self._registry is None: + self._registry = load_registry_digest() + try: + self._registry(name, tag) + return "ok" + except ValueError as e: + if re.search(r"HTTP 404\b", str(e)): + return "missing" + raise ToolError(str(e)) from None + except (urllib.error.URLError, OSError) as e: + raise ToolError("%s:%s: %s" % (name, tag, e)) from None + + def aur_version(self): + url = os.environ.get("WATCHDOG_AUR_URL") or AUR_URL + try: + with urllib.request.urlopen(url, timeout=30) as r: + doc = json.load(r) + except (urllib.error.URLError, OSError, ValueError) as e: + raise ToolError("AUR RPC %s: %s" % (url, e)) from None + res = doc.get("results") or [] + return res[0]["Version"].split("-", 1)[0] if res else None + + def release_gaps(self, ver, newest): + owner = self.repo.split("/", 1)[0].lower() + ghcr = "ghcr.io/%s/k3d-gpu" % owner + gaps = [] + hub_missing = self.registry_status("docker.io/" + HUB_REPO, ver) == "missing" + if hub_missing: + gaps.append("Docker Hub: %s:%s has no manifest (HTTP 404)" % (HUB_REPO, ver)) + if self.registry_status(ghcr, ver) == "missing": + gaps.append("GHCR: %s:%s has no manifest (HTTP 404)" % (ghcr, ver)) + want = ["k3d-gpu_%s-1_all.deb" % ver, "k3d-gpu-%s-1.noarch.rpm" % ver, "SHA256SUMS"] + try: + rel = self.gh.json("release", "view", "aur-v" + ver, "-R", self.repo, "--json", "assets") or {} + have = {a["name"] for a in rel.get("assets", []) if a.get("state", "uploaded") == "uploaded"} + missing = [w for w in want if w not in have] + if missing: + gaps.append("GitHub release aur-v%s lacks %s" % (ver, ", ".join(missing))) + except ToolError as e: + if "release not found" not in str(e): + raise + gaps.append("GitHub release aur-v%s does not exist (no %s)" % (ver, ", ".join(want))) + if newest: + aur = self.aur_version() + if aur is None or not re.match(r"^\d+\.\d+\.\d+$", aur) or vkey(aur) < vkey(ver): + gaps.append("AUR: k3d-gpu is at %s, not %s" % (aur or "nothing", ver)) + return gaps, hub_missing + + def release_tags(self): + p = subprocess.run(["git", "-C", self.root, "for-each-ref", "--format=%(refname:strip=2) %(creatordate:unix)", + "refs/tags/aur-v*"], capture_output=True, text=True) + if p.returncode != 0: + raise ToolError("git for-each-ref: %s" % p.stderr.strip()) + out = [] + for ln in p.stdout.splitlines(): + name, _, when = ln.partition(" ") + m = TAG_RE.match(name) + if m and when.isdigit(): + out.append((".".join(m.groups()), int(when))) + return sorted(out, key=lambda t: vkey(t[0]), reverse=True) + + def check_release(self): + recent = self.gh.json("run", "list", "-R", self.repo, "--limit", "100", + "--json", "workflowName,status,databaseId") or [] + busy = sorted({r["workflowName"] for r in recent + if r.get("workflowName") in RELEASE_WORKFLOWS and r.get("status") != "completed"}) + if busy: + print("release check deferred: %s is queued or running" % ", ".join(busy)) + return + cands = [(v, t) for v, t in self.release_tags() if self.now - t >= 45 * MINUTE] + if not cands: + return + ver = cands[0][0] + key = "release:" + ver + gaps, hub_missing = self.release_gaps(ver, newest=True) + for k in self.alarms.keys("release:"): + if k != key: + self.alarms.clear(k, "Superseded: the newest release older than 45 min is %s%s." + % (ver, "" if gaps else ", and it is complete")) + if not gaps: + self.alarms.clear(key, "Release %s is complete: images, packages and AUR." % ver) + return + body = ["Release %s (tag aur-v%s) is incomplete:" % (ver, ver), ""] + ["- " + g for g in gaps] + if not hub_missing and any(g.startswith(("Docker Hub", "GHCR")) for g in gaps): + body += ["", "Docker Hub has %s:%s, so a release build would stop at its overwrite guard; " + "push the GHCR tag by hand or cut a new release." % (HUB_REPO, ver)] + info = [] + for v, t in cands[1:21]: + if self.now - t > 14 * DAY: + break + try: + g, _ = self.release_gaps(v, newest=False) + except ToolError as e: + g = ["not checked: %s" % str(e)[:200]] + if g: + info.append("- %s: %s" % (v, "; ".join(g))) + if info: + body += ["", "Older releases from the last 14 days with gaps (information only; a newer release " + "supersedes them):", ""] + info + title = "release %s is incomplete" % ver + if not hub_missing: + self.alarms.raise_(key, title, "\n".join(body)) + return + self.raise_dispatching(key, title, body, ver, + ("workflow", "run", "build-and-push.yml", "-R", self.repo, "--ref", "main", + "-f", "release=" + ver), + "Dispatched `build-and-push.yml -f release=%s` once; it builds the image from the " + "tag and then dispatches the AUR and package releases. It is not dispatched again." + % ver) + + # ── e + f ──────────────────────────────────────────────────────────── + + def check_bot_prs(self): + prs = self.gh.json("pr", "list", "-R", self.repo, "--state", "open", "--limit", "100", + "--json", "number,headRefName,headRefOid,createdAt,url,title,isCrossRepository") or [] + stuck = [] + for pr in sorted(prs, key=lambda p: p["number"]): + # A fork can name its branch autobump/* too; only this repository's branches are bot PRs. + if (pr.get("isCrossRepository") or not pr["headRefName"].startswith("autobump/") + or self.now - ts(pr["createdAt"]) <= 72 * HOUR): + continue + runs = self.gh.json("run", "list", "-R", self.repo, "--workflow", "ci.yml", "--commit", pr["headRefOid"], + "--limit", "20", "--json", "databaseId,event,status,conclusion") or [] + if any(r.get("conclusion") == "success" for r in runs): + continue + seen = sorted({"%s %s" % (r.get("event"), r.get("conclusion") or r.get("status")) for r in runs}) + stuck.append("- %s %s (`%s`, open since %s): ci.yml at %s: %s" % ( + pr["url"], pr["title"], pr["headRefName"], pr["createdAt"], pr["headRefOid"][:12], + ", ".join(seen) or "no run")) + if stuck: + self.alarms.raise_("bot-pr-stuck", "bot PRs open over 72 h without green CI", "\n".join( + ["These autobump PRs are open over 72 h and their head SHA has no successful ci.yml run:", ""] + + stuck + ["", "A PR that GITHUB_TOKEN opened or updated gets approval-held CI runs (action_required): " + "approve them, or dispatch ci.yml on the branch."])) + else: + self.alarms.clear("bot-pr-stuck", "No autobump PR is stuck any more.") + + def check_simulate(self, simulate): + if simulate: + self.alarms.raise_("simulate", "simulated red", "\n".join([ + "The watchdog was dispatched with simulate_red=true: this checks that an alarm reaches its owner.", + "", "The next watchdog run without simulate_red closes it."])) + else: + self.alarms.clear("simulate", "The next watchdog run without simulate_red closed it.") + + # ── run ────────────────────────────────────────────────────────────── + + def run(self, simulate): + meta = self.guard("workflow state", self.check_workflow_state) + try: + scheduled = scheduled_workflows(self.root) + except (OSError, ValueError) as e: + scheduled = [] + self.errors.append("reading scheduled workflows: %s" % e) + for wf in scheduled: + self.guard("scheduled %s" % wf["file"], self.check_scheduled, wf, meta) + self.guard("main CI", self.check_main_ci) + self.guard("release integrity", self.check_release) + self.guard("bot PRs", self.check_bot_prs) + self.guard("simulate_red", self.check_simulate, simulate) + + fatal = False + if self.errors: + for e in self.errors: + print("::error title=watchdog::%s" % e.splitlines()[0][:400], file=sys.stderr) + body = "\n".join(["The watchdog hit %d error(s); the checks they stopped did not run:" % len(self.errors), + "", "```text", *[e.replace("```", "'''") for e in self.errors], "```"]) + try: + self.alarms.raise_("watchdog-error", "the watchdog could not complete its checks", body) + except ToolError as e: + print("::error title=watchdog::cannot file watchdog-error: %s" % e, file=sys.stderr) + fatal = True + else: + try: + self.alarms.clear("watchdog-error", "A watchdog run completed every check.") + except ToolError as e: + print("::error title=watchdog::cannot close watchdog-error: %s" % e, file=sys.stderr) + fatal = True + + if self.alarms.undelivered: + print("::error title=watchdog::%d alarm(s) not filed (%s): this repository has issues disabled; " + "enable Issues in Settings > General > Features" % (len(self.alarms.undelivered), + ", ".join(self.alarms.undelivered)), + file=sys.stderr) + fatal = True + self.summary() + return 1 if fatal else 0 + + def summary(self): + lines = ["## Watchdog %s" % iso(self.now), ""] + lines += ["- open: `%s` %s" % (k, t) for k, t in self.alarms.opened] + lines += ["- closed: `%s`" % k for k in self.alarms.closed] + lines += ["- not filed: `%s`" % k for k in self.alarms.undelivered] + lines += ["- error: %s" % e.splitlines()[0][:300] for e in self.errors] + if len(lines) == 2: + lines.append("No alarm.") + text = "\n".join(lines) + "\n" + print(text) + if os.environ.get("GITHUB_STEP_SUMMARY"): + with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as f: + f.write(text) + + +def main(argv): + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("--root", default=REPO_ROOT) + ap.add_argument("--list-scheduled", action="store_true") + a = ap.parse_args(argv) + if a.list_scheduled: + for wf in scheduled_workflows(a.root): + print("%s %s" % (wf["file"], hours(wf["period"]))) + return 0 + repo = os.environ.get("GH_REPO") or os.environ.get("GITHUB_REPOSITORY") + if not repo: + print("watchdog: set GH_REPO or GITHUB_REPOSITORY (owner/repo)", file=sys.stderr) + return 2 + now = float(os.environ.get("WATCHDOG_NOW") or datetime.datetime.now(datetime.timezone.utc).timestamp()) + simulate = os.environ.get("SIMULATE_RED", "").strip().lower() == "true" + return Watchdog(a.root, repo, now).run(simulate) + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/images.lock b/images.lock index a7b8bdb7..5052f203 100644 --- a/images.lock +++ b/images.lock @@ -62,7 +62,7 @@ "sources": [ "scripts/k3d-gpu K3D_GPU_AGENT_VIDEO_IMAGE" ], - "tag": null + "tag": "2026.2.0-ubuntu24" }, { "digest": "sha256:386e6bec28b3f25a0e4e1c0fb726f026e8ed199bd74e4aa0fb226631359f7daf", @@ -127,7 +127,7 @@ "sources": [ "scripts/k3d-gpu AGENT_DEFAULT_IMAGE" ], - "tag": null + "tag": "1.5" }, { "digest": "sha256:dd9874fb6a29aa6b3070754760876b42d0e440bb34537d7196d51bc15abb0068", @@ -190,14 +190,7 @@ "digest": "sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58", "name": "docker.io/openvino/model_server", "sources": [ - "scripts/k3d-gpu AGENT_OVMS_IMAGE" - ], - "tag": null - }, - { - "digest": "sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58", - "name": "docker.io/openvino/model_server", - "sources": [ + "scripts/k3d-gpu AGENT_OVMS_IMAGE", "scripts/k3d-gpu NPU_UMD_IMAGE", "share/npu-embeddings.yml" ], @@ -297,7 +290,7 @@ "sources": [ "scripts/k3d-gpu AGENT_DEFAULT_IMAGE" ], - "tag": null + "tag": "v0.32.0" }, { "digest": "sha256:73cbb215d3d43ae5b42816217b200386eb08d567767fcb36cba163152c5f3c79", @@ -316,14 +309,6 @@ ], "tag": "v0.15.0" }, - { - "digest": "sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", - "name": "ghcr.io/ggml-org/llama.cpp", - "sources": [ - "scripts/k3d-gpu K3D_GPU_LLAMA_VULKAN_IMAGE" - ], - "tag": null - }, { "digest": "sha256:7afc81bfe7a93de287324044fe86ccc70b1b7ac8832ed8e6ea658e11fb448ddf", "name": "ghcr.io/ggml-org/llama.cpp", @@ -348,13 +333,21 @@ ], "tag": "server-intel-b11960" }, + { + "digest": "sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", + "name": "ghcr.io/ggml-org/llama.cpp", + "sources": [ + "scripts/k3d-gpu K3D_GPU_LLAMA_VULKAN_IMAGE" + ], + "tag": "server-vulkan-b11960" + }, { "digest": "sha256:9b5424634b74f0c6905109fc46f90a7f569ab7bde1211d3ac5f9c32a17895e60", "name": "ghcr.io/home-operations/drm-exporter", "sources": [ "share/telemetry/intel-gpu-exporter.yml" ], - "tag": null + "tag": "0.3.4" }, { "digest": "sha256:1479956240397c7207c3d38c0d296bd591cd66f26d432452f9a2d75336843776", @@ -378,7 +371,7 @@ "sources": [ "share/agent-chat-ui.yml" ], - "tag": null + "tag": "v0.12.2" }, { "digest": "sha256:27c1b2553a690ca3d29889dcd61df97525d46f58272c0be8dc63c2b18f120075", @@ -442,7 +435,7 @@ "sources": [ "scripts/k3d-gpu AGENT_DEFAULT_IMAGE" ], - "tag": null + "tag": "1.24.2-481" } ], "k3s_tag": "v1.37.1-k3s1", diff --git a/images.unpinned b/images.unpinned index 98180692..aaa5a308 100644 --- a/images.unpinned +++ b/images.unpinned @@ -1,6 +1,7 @@ -# Image references allowed without a digest pin. `scripts/images-lock pins` (CI and -# tests/image_pins_test.sh) fails on any other name:tag, bare name or :latest -# reference, and on a line here that matches nothing. +# Image references allowed without a digest pin, or with a digest but no tag. +# `scripts/images-lock pins` (CI and tests/image_pins_test.sh) fails on any other +# name:tag, bare name or :latest reference, on a name@sha256 with no tag outside the +# ledger and historical paths of pins/classes, and on a line here that matches nothing. # # <path glob> <reference, or * for every reference in the file> <reason> @@ -30,3 +31,9 @@ tests/model_share_test.sh localhost:5000/agent chec tests/image_pins_test.sh * the pin gate's own unpinned inputs tests/eat_test.sh * stubs of EAT's gateway chart files, the input gateway-charts.py pins tests/ledger_freshness_test.py cryptoandcoffee/k3d-gpu:0.1.70 a fixture ledger row's node image the re-measure queue parses, never pulled +tests/pin_classes_test.py * the pin-token parser's own inputs + +# Digest-only test inputs: the form a user may pass, or an old release pinned. +tests/detect_test.sh intel/intel-gpu-plugin@* a fake digest-only plugin image: up must still add its args +tests/llamacpp_test.sh openvino/model_server@* a digest-only K3D_GPU_AGENT_IMAGE the fit must not hand to llama.cpp +oracle/test_oracle.py openvino/model_server@* release 0.1.19's OVMS pin (digest only then), the server image the seed bundles record diff --git a/integrations/eat/README.md b/integrations/eat/README.md index 28c35356..3888cb52 100644 --- a/integrations/eat/README.md +++ b/integrations/eat/README.md @@ -71,6 +71,7 @@ that off the host. | `agentic-config.cfg` | EAT's own component toggles: gateway, ingress, Redis, pgvector, agent sandbox on; Kubespray, observability, models, KubeRay, CPU balloons off; `release-1.1.0`'s guardrail, vLLM Semantic Router, OpenShell, Kata, Ceph and rack-scale mode written out `off` | | `vllm-values.yaml` | k3d-gpu values merged over EAT's `vllm/xeon-values.yaml` (memory limit, P-core balloon, the `v0.28.0` image by digest) | | `gateway-charts.py` | writes pinned copies of EAT's GenAI Gateway chart directories; `brownfield.sh` passes them to `deploy-genai-gateway.yml` | +| `guard-values.py` | with `deploy_guardrails=on`, puts the guard's `vllm/vllm-openai-cpu` image at `vllm-values.yaml`'s digest in a copy of EAT's charts; `brownfield.sh` passes that copy to `deploy-guardrails.yml` | | `gateway-values.yaml` | the gateway chart's pins: postgresql and redis chart versions, LiteLLM, PostgreSQL, Redis and init images by digest | | `gateway-trace-values.yaml` | the Langfuse trace stack's pins: Langfuse web and worker, PostgreSQL, Valkey, ClickHouse, ZooKeeper, MinIO by digest | | `eat-check.sh` | the end-state check below | @@ -276,6 +277,17 @@ except where an item says otherwise, and item 11 is new there. In `eat-up.sh` stops before touching the cluster when the config gives `deploy_guardrails` no value (a config written for `release-1.0.3-hotfix` has none) and asks for `off` or `on`. + With `on`, the guard's image is pinned too. `deploy-guardrails.yml` installs + EAT's `vllm` chart with `guardrail-values.yaml` (lines 115 and 133-135), which + names `vllm/vllm-openai-cpu:v0.28.0` by tag only. Before anything deploys, + `brownfield.sh` copies EAT's `core/helm-charts` to `~/k3d-gpu-guard-charts`, + and `guard-values.py` sets that copy's guard tag to the digest `vllm-values.yaml` + pins for the same image. That playbook alone gets the copy, as the extra var + `remote_helm_charts_base` (nothing else in it reads that variable). The deploy + stops if EAT's guard names another repository or tag, or if `EAT_VLLM_VALUES` + has no digest. Rendered with Helm v4.1.1 on 2026-10-29 (`helm template` on + `0c726f5`'s `vllm` chart, the playbook's values), the guard's manifests differ + from EAT's only in the `image:` line. The guard itself has not run here. 12. **Release-1.1.0 settings that collide with a k3d-gpu cluster.** `eat-up.sh` stops before touching the cluster, reading each key as `read_config_file` does, on `compute_platform=xpu` (fresh-install.sh:219 applies @@ -307,7 +319,7 @@ Pinned here (checked 2026-10-29): | EAT | `release-1.1.0`, commit `0c726f576595971c53cc45b94e08ba969beb4a4a`; `eat-up.sh` refuses any other HEAD | `eat-up.sh`; the latest EAT release ([releases](https://github.com/intel/enterprise-agent-toolkit/releases/tag/release-1.1.0), published 2026-10-22) | | bastion base | `ubuntu:24.04` by digest | `bastion.Dockerfile` | | Helm in the bastion | v3.14.0 by sha256, the version `setup-bastion.yml` asks for | `bastion.Dockerfile` | -| vLLM CPU | `vllm/vllm-openai-cpu:v0.28.0@sha256:197354e6...df9647`, the tag `release-1.1.0`'s `xeon-values.yaml` names (line 34) | `vllm-values.yaml` | +| vLLM CPU | `vllm/vllm-openai-cpu:v0.28.0@sha256:197354e6...df9647`, the tag `release-1.1.0`'s `xeon-values.yaml` names (line 34); with `deploy_guardrails=on` the guard's image too (`guardrail-values.yaml`, line 30) | `vllm-values.yaml` | | GenAI Gateway | postgresql 18.12.4 and redis 28.3.1 charts; 11 images by digest (below) | `gateway-values.yaml`, `gateway-trace-values.yaml` | EAT itself pins ingress-nginx 4.12.2 (`ingress_controller` in diff --git a/integrations/eat/brownfield.sh b/integrations/eat/brownfield.sh index cf969c2a..43c36a7f 100755 --- a/integrations/eat/brownfield.sh +++ b/integrations/eat/brownfield.sh @@ -102,8 +102,24 @@ if [ "${mode}" = "stack" ]; then gateway_vars="${HOME}/k3d-gpu-gateway-vars.yml" printf 'helm_charts_base_genai: %s\nhelm_charts_base_genai_trace: %s\n' \ "${charts}/genai-gateway" "${charts}/genai-gateway-trace" > "${gateway_vars}" + # Guardrail: deploy-guardrails.yml installs {{ remote_helm_charts_base }}/vllm with + # its guardrail-values.yaml (release-1.1.0 lines 115 and 133-135), which names the + # vLLM CPU image by tag only; nothing else in that play reads remote_helm_charts_base. + # That playbook alone gets a copy of EAT's charts whose guardrail-values.yaml carries + # the digest EAT_VLLM_VALUES pins for the same image (guard-values.py). The copy is + # made, and checked, before anything deploys. + guard_vars="${HOME}/k3d-gpu-guard-vars.yml" + guard_marker="${HOME}/k3d-gpu-guard-pinned" + rm -f "${guard_vars}" "${guard_marker}" + if [ "${DEPLOY_GUARDRAILS:-off}" = on ]; then + guard_charts="${HOME}/k3d-gpu-guard-charts" + rm -rf "${guard_charts}" + cp -a "${CORE_DIR}/helm-charts" "${guard_charts}" + python3 "${K3D_GPU_EAT_DIR}/guard-values.py" "${guard_charts}/vllm/guardrail-values.yaml" "${EAT_VLLM_VALUES}" + printf 'remote_helm_charts_base: %s\n' "${guard_charts}" > "${guard_vars}" + fi # EAT's functions run in this shell and call ansible-playbook by name. The - # marker file records that the gateway playbook got the copies. + # marker files record that the gateway and guard playbooks got the copies. gateway_marker="${HOME}/k3d-gpu-gateway-pinned" rm -f "${gateway_marker}" ansible-playbook() { @@ -111,6 +127,10 @@ if [ "${mode}" = "stack" ]; then *"deploy-genai-gateway.yml "*) : > "${gateway_marker}" command ansible-playbook "$@" --extra-vars "@${gateway_vars}" ;; + *"deploy-guardrails.yml "*) + [ -f "${guard_vars}" ] || { echo "ERROR: deploy-guardrails.yml called with DEPLOY_GUARDRAILS=${DEPLOY_GUARDRAILS:-}, and no pinned chart copy" >&2; return 1; } + : > "${guard_marker}" + command ansible-playbook "$@" --extra-vars "@${guard_vars}" ;; *) command ansible-playbook "$@" ;; esac } @@ -125,6 +145,10 @@ if [ "${mode}" = "stack" ]; then echo "ERROR: deploy-genai-gateway.yml never ran through the pinning wrapper" >&2 exit 1 fi + if [ "${DEPLOY_GUARDRAILS:-off}" = on ] && [ ! -f "${guard_marker}" ]; then + echo "ERROR: deploy-guardrails.yml never ran through the wrapper that pins the guard's image" >&2 + exit 1 + fi exit fi diff --git a/integrations/eat/guard-values.py b/integrations/eat/guard-values.py new file mode 100755 index 00000000..a5e302b5 --- /dev/null +++ b/integrations/eat/guard-values.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""guard-values.py: the guard's vLLM image by digest, in a copy of EAT's charts. + + guard-values.py GUARD_VALUES VLLM_VALUES + +GUARD_VALUES is vllm/guardrail-values.yaml in a copy of EAT's core/helm-charts, +the values deploy-guardrails.yml installs the guard's vLLM with (release-1.1.0: +lines 115 and 133-135). EAT names vllm/vllm-openai-cpu there by tag only. Its +image.tag is rewritten to the tag VLLM_VALUES pins for the same image, the +digest k3d-gpu already serves the models with, when: + + - VLLM_VALUES pins image.tag as <tag>@sha256:<digest> + - both files name the same image.repository + - EAT's tag is the <tag> part of that pin, so the pin only adds a digest + +Otherwise it exits 1 naming both, and GUARD_VALUES is left as it was. Every +other key is kept. Needs python3 and PyYAML (the bastion has both). +""" + +import re +import sys + +import yaml + +PINNED = re.compile(r"^[^@\s]+@sha256:[0-9a-f]{64}$") + + +def die(msg): + sys.stderr.write("guard-values: %s\n" % msg) + sys.exit(1) + + +def image(path): + with open(path) as f: + doc = yaml.safe_load(f) or {} + img = doc.get("image") + if not isinstance(img, dict) or not img.get("repository") or not img.get("tag"): + die("%s has no image with a repository and a tag" % path) + return doc, img + + +def main(): + if len(sys.argv) != 3: + die("usage: guard-values.py GUARD_VALUES VLLM_VALUES") + guard_path, vllm_path = sys.argv[1:] + guard, g = image(guard_path) + _, v = image(vllm_path) + pin = str(v["tag"]) + if not PINNED.match(pin): + die("%s: image.tag %r is not <tag>@sha256:<digest>" % (vllm_path, pin)) + if g["repository"] != v["repository"]: + die("EAT's guard runs %s, %s pins %s" % (g["repository"], vllm_path, v["repository"])) + if str(g["tag"]) != pin.split("@")[0]: + die("EAT's guard runs %s:%s, %s pins %s:%s" % (g["repository"], g["tag"], vllm_path, v["repository"], pin)) + g["tag"] = pin + with open(guard_path, "w") as f: + yaml.safe_dump(guard, f, sort_keys=False) + print("guard image: %s:%s" % (g["repository"], pin)) + + +if __name__ == "__main__": + main() diff --git a/oracle/bundle.py b/oracle/bundle.py index eac88025..25273214 100644 --- a/oracle/bundle.py +++ b/oracle/bundle.py @@ -63,6 +63,15 @@ def image_registry(ref: str) -> str | None: return None +def untagged(ref: str) -> str: + """name@sha256:<digest> for a ref that carries a digest, its tag dropped. A bundle + records the server image by digest alone; the launcher pins name:tag@sha256:...""" + name, at, digest = ref.partition("@") + if at and ":" in name.rsplit("/", 1)[-1]: + name = name.rsplit(":", 1)[0] + return name + at + digest + + def public_image(ref: str) -> bool: host = image_registry(ref) return host is None or host in PUBLIC_REGISTRIES diff --git a/oracle/project.py b/oracle/project.py index 22f69c60..d82bb096 100644 --- a/oracle/project.py +++ b/oracle/project.py @@ -283,8 +283,9 @@ def model_block(model_id, revision, weights_sha256, weights_bytes, quant): def tool_block(version, server_image, k3s=None): if not re.match(r"^([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|git-[0-9a-f]{40})$", version or ""): raise ProjectionError(f"tool version {version!r} is neither a release (x.y.z) nor git-<sha>") + server_image = B.untagged(server_image or "") return {"version": version, "k3s": k3s, - "server_image": server_image if DIGEST_IMAGE_RE.match(server_image or "") + "server_image": server_image if DIGEST_IMAGE_RE.match(server_image) and B.public_image(server_image) else None, "node_image_digest": None} diff --git a/oracle/test_oracle.py b/oracle/test_oracle.py index 9323b28e..1f987d4a 100644 --- a/oracle/test_oracle.py +++ b/oracle/test_oracle.py @@ -440,6 +440,16 @@ def test_V4_release_and_provenance(self): self.assertEqual(check(reseal(b)).verdict, "reject") self.assertEqual(check(reseal(b), trusted=True).verdict, "accept") + def test_V4_tagged_release_pin_matches_the_digest(self): + # A release pins name:tag@sha256:X; a bundle records the image as name@sha256:X. + name, digest = AGENT_IMAGE.split("@") + tagged = "%s:2026.4.1-gpu@%s" % (name, digest) + self.assertEqual(check(self.b, pins={"0.1.19": tagged}).verdict, check(self.b).verdict) + self.assertNotIn("V4", rules(check(self.b, pins={"0.1.19": tagged}))) + self.assertEqual(V.script_pins('AGENT_OVMS_IMAGE="%s"\n' % tagged), {AGENT_IMAGE}) + self.assertEqual(P.tool_block("0.1.19", tagged)["server_image"], AGENT_IMAGE) + self.assertEqual(P.tool_block("0.1.19", AGENT_IMAGE)["server_image"], AGENT_IMAGE) + def test_V5_tampered_claims_are_rejected(self): b = copy.deepcopy(self.b) b["results"]["capacity"]["agents_per_box"] += 1 diff --git a/oracle/validate.py b/oracle/validate.py index 3f0f4ae6..dc946cf7 100644 --- a/oracle/validate.py +++ b/oracle/validate.py @@ -218,8 +218,8 @@ def to_us(v): def script_pins(text): - """Every server image a k3d-gpu script pins by digest.""" - return {a or b for a, b in AGENT_IMAGE_RE.findall(text or "")} + """Every server image a k3d-gpu script pins by digest, as name@sha256:...""" + return {B.untagged(a or b) for a, b in AGENT_IMAGE_RE.findall(text or "")} def release_pin(version, repo=REPO): @@ -237,7 +237,7 @@ def release_pin(version, repo=REPO): def as_pin_set(pin): if pin is None: return None - return {pin} if isinstance(pin, str) else set(pin) + return {B.untagged(p) for p in ([pin] if isinstance(pin, str) else pin)} def check_release(b, res, trusted, pins, repo): diff --git a/pins/classes b/pins/classes new file mode 100644 index 00000000..6dad2afd --- /dev/null +++ b/pins/classes @@ -0,0 +1,118 @@ +# Pin classes: who may move the pins (image refs, pkg==version, commits, *_TAG/ +# *_VERSION/*_IMAGE/... assignments) in each path. hack/pin-classes reads this file, +# hack/pin-guard enforces it on every PR and push to main, and `scripts/images-lock +# repin` leaves ledger and historical paths alone. +# +# <glob> ours|eat-stock|historical [reason] +# <glob> ledger <rows csv> amend=<aliases csv> [reason] +# historical-ref <path> <exact string> +# +# First match wins; * also matches /. A path no line matches is ours, except a file +# under bench/ or oracle/ that holds a pin: hack/pin-classes coverage fails on it. +# +# ours k3d-gpu's own pins: always the latest stable release. +# eat-stock tracks Intel EAT's latest release (intel/enterprise-agent-toolkit); reviewed. +# ledger a ledger row's runner or records. While any listed row is PENDING in the +# EXPERIMENTS.md Hypotheses table the path is an arm: a pin changes only +# with a dated amendment in the same diff whose heading names a PENDING +# listed row or an alias ("### <alias> amendment <ID>: <title> (YYYY-MM-DD)"). +# Once a listed row has a verdict and none is PENDING it is historical. +# historical a finished run's record: never modified, deleted or renamed. +# historical-ref a finished run's pin inside a live runner: it keeps occurring as +# often as it does. + +# --- EAT stock: what Intel's Enterprise Agent Toolkit ships -------------------------- +integrations/eat/** eat-stock EAT's Helm values, gateway charts and bastion, tracked to EAT's latest release +examples/kuberay/** eat-stock the RayCluster EAT's KubeRay path deploys +examples/agents/litellm-gateway/** eat-stock EAT's genai-gateway (LiteLLM) agent example +contrib/eat/** eat-stock the patch and values contributed to EAT +integrations/eas/** eat-stock EAS, the stack EAT 2.0 deploys through: pins.json freezes what EAS's own configs resolve to + +# --- Finished records ------------------------------------------------------------------ +console/matrix.v1.json historical the measured cells of H6 and H8 the planner reads +oracle/seed/** historical the maintainer bundles of the H6 and H8 runs +oracle/raw/** historical the raw H6 and H8 records the seed bundles were built from + +# --- Runners of finished rows. Ledger lines, so the class follows the ledger: every row +# listed has a verdict, so each resolves to historical (hack/pin-classes effective). +bench/amx-autoquant/** ledger H27,H27x amend=Cross-host H27 REFUTED; owner decision pending (H27x REFUTED too) +bench/apb/arm.sh ledger H8 amend= H8's arms +bench/apb/vllm-cuda.yaml ledger H8 amend= H8's vLLM CUDA arm (A3) +bench/apb/h7.sh ledger H7 amend= H7's runner +bench/dra_round.sh ledger H16,H17 amend= the H16/H17 harness +bench/noisy.sh ledger H3,H26 amend= H3 and H26's noisy neighbour +bench/rdt-isolate/** ledger H26 amend= H26's runner +bench/thermal-resource/** ledger H23 amend= H23's runner and records +bench/prefill-guard/cpufreq.sh ledger H5,H55 amend=H55 H5's cpufreq snapshot; H55's admission runner reuses it +bench/prefill-guard/prompt.txt ledger H5,H55 amend=H55 H5's prompt; H55's admission runner reuses it +bench/prefill-guard/** ledger H5 amend= H5's stage-1 harness and records +bench/h54gpu/** ledger H54b,H54c amend= H54b and H54c's runner and records +bench/h54/diag/** ledger H54a amend=H54 H54 note N2, the B1g stall diagnostic of H54a +bench/eatflow/*constraints.txt ledger H40,H41,H42,H43,H44,H45,H46,H47,H48 amend= the H40-H48 BFCL and coding-agent pip sets + +# --- Records of finished rows inside live runner trees ------------------------------- +bench/eatflow/results/2026-10-17/** ledger H40,H41,H42,H43,H44,H45,H46,H47,H48 amend=Crescent the H40-H48 desktop run +bench/eatflow/results/2026-10-18/** ledger H47,H49,H47b,H49b amend=Crescent the H47, H49, H47b and H49b runs +bench/eatflow/results/2026-10-19/** ledger H43,H45 amend=Crescent the H43 and H45 runs +bench/eatflow/results/2026-10-20/** ledger H50,H50o,H51,H52 amend=Crescent,H52 the first crescent runs +bench/eatflow/results/2026-10-21/** ledger H50,H50o,H52b,H56 amend=Crescent,H52 H52b's pre-check, H56 stage 1, the QAT export +bench/eatflow/results/*/H52b*/** ledger H52b amend=Crescent,H52 H52b's run +bench/h54/results/2026-10-21/** ledger H54a,H54d amend=H54 H54a's run and H54d's not-run record +bench/h54/results/*/B1g-stall-diag/** ledger H54a amend=H54 H54 note N2's records +bench/h57d/results/2026-10-21/** ledger H57da,H57dm,H57db amend=H57d the H57d run +bench/h57d/results/2026-10-29/** ledger H57dp,H57de amend=H57d the H57dp and H57de run +bench/hetero-spec/results/** ledger H22,H34 amend=H34 the H22 and H34 runs +bench/xhost/results/2026-10-20/** ledger H25x,H27x,H28x,H34x amend=Cross-host the first cross-host runs + +# Pins of finished rows that live runners keep so those rows reproduce. +historical-ref bench/eatflow/live.sh ${LIVE_EAT_B1_TAG:-v0.24.0@sha256:b0e6f8604413a8bfcca8836bb4b57b47f1aa653957e07f8903ece531309981a1} +historical-ref bench/eatflow/live.sh image=vllm/vllm-openai@sha256:a4a4c0437bf7240089da5f08aa370c4aee17ae5290f7a3b468825ee26c4c3a6b +historical-ref bench/eatflow/live.sh ollama/ollama:0.40.5@sha256:5d3a2f25f9825628daba94dc0b1d421c7d57d6c03ae5faa9ba050e058bab8241 +historical-ref bench/eatflow/live.sh ghcr.io/ggml-org/llama.cpp:server-b11706@sha256:f41d47eee678c02656eefc9ed8cce76cab584aa7032805eeef0e6639fcfda485 +historical-ref bench/h54/h54.py "vllm024": "vllm/vllm-openai-cpu:v0.24.0@sha256:b0e6f8604413a8bfcca8836bb4b57b47f1aa653957e07f8903ece531309981a1" +historical-ref bench/h54/h54.py "ovms_eat": "2025.4@sha256:c370720007564bbdcbca13b9b714e4537068542cda512e8095259e66b617962a" + +# --- Ledger rows: runners of PENDING rows and their finished siblings ---------------- +bench/eatflow/agent.lock ledger H40,H41,H42,H43,H44,H45,H46,H47,H48,H50,H50o,H53,H56 amend=Crescent the agent lock: H40-H48, then crescent M1's Gemma 4 and H53's Qwen3.8 sampling +bench/eatflow/live.sh ledger H40,H41,H42,H43,H44,H45,H46,H47,H48,H47b,H49,H49b,H50,H50o,H51,H52,H52b,H53,H56,H58,H62 amend=Crescent,H52,H62 the agent-flow runner (desktop rows, then crescent) +bench/eatflow/crescent/serving.py ledger H50,H50o,H51,H52,H52b,H53,H56,H58,H59,H7x,H25x,H27x,H28x,H34x,H60 amend=Crescent,H52,H59,Cross-host crescent's serving sampler; the cross-host holds and H59's crescent holds run it +bench/eatflow/crescent/** ledger H50,H50o,H51,H52,H52b,H53,H56,H58,H62 amend=Crescent,H52,H62 the crescent wrappers, setup and holds +bench/eatflow/** ledger H40,H41,H42,H43,H44,H45,H46,H47,H48,H47b,H49,H49b,H50,H50o,H51,H52,H52b,H53,H56,H58,H62 amend=Crescent,H52,H62 the agent-flow harness (pairs, solo, BFCL, tasks) +bench/apb/h7x* ledger H7x amend=Cross-host H7x's runner +bench/apb/test_h7x.py ledger H7x amend=Cross-host H7x's runner tests +bench/xhost/fetch_models.py ledger H7x,H25x,H27x,H28x,H34x,H60,H18w,H25w,H28w,H34w,H52w,H57wa,H57wm,H57wb,H59w amend=Cross-host,Wildct the cross-host model fetch; wildct's setup-models.sh runs it +bench/xhost/** ledger H7x,H25x,H27x,H28x,H34x,H60 amend=Cross-host the cross-host workspace, lock and holds +bench/h54/h54.py ledger H54a,H54q,H59,H59d,H59w,H60 amend=H54,H59,H60 H54's scorer: H59's scorer imports it, H60's runner calls its void and gsm8k +bench/h54/test_h54.py ledger H54a,H54q,H59,H59d,H59w,H60 amend=H54,H59,H60 h54.py's tests +bench/h54/gsm8k-test-0-199.jsonl ledger H54a,H54q,H59,H59d,H59w,H60 amend=H54,H59,H60 the GSM8K items H54's, H59's and H60's quality holds score +bench/h54/** ledger H54a,H54q amend=H54 the H54 runner +bench/h57d/h57d.py ledger H57da,H57dm,H57db,H57dp,H57de,H57wa,H57wm,H57wb,H59,H59d,H59w amend=H57d,Wildct,H59 H57d's scorer: H59's scorer imports its load gate +bench/h57d/** ledger H57da,H57dm,H57db,H57dp,H57de,H57wa,H57wm,H57wb amend=H57d,Wildct the H57d runner; wildct's H57w rows run it (bench/wildct/row.sh h57w) +bench/h59/** ledger H59,H59d,H59w amend=H59 H59's scorer +bench/h62/** ledger H62 amend=H62 H62's runner (the EAS and EAT 2.0 VM pairs on crescent) +bench/flashnext/** ledger H60 amend=H60 H60's runner +bench/admission/** ledger H55 amend=H55 H55's SLO-gated admission harness +bench/models/** ledger H50,H50o,H57da,H57dm,H57db,H57dp,H57de amend=Crescent,H57d the Gemma 4 QAT export and grid (crescent amendment M1, H57d) +bench/wildct/** ledger H18w,H25w,H28w,H34w,H52w,H57wa,H57wm,H57wb,H59w amend=Wildct the wildct client host's runner +bench/load/** ledger H1b,H20,H21,H28,H30,H31,H32,H40,H41,H42,H43,H44,H45,H46,H47,H47b,H48,H49,H49b,H50,H50o,H51,H52,H52b,H53,H56,H58,H7x,H25x,H27x,H28x,H34x,H60,H18w,H25w,H28w,H34w,H52w,H57wa,H57wm,H57wb,H59w amend=Wildct,Crescent,H52,Cross-host the matched background-load harness (amendment 27); live.sh, crescent's and the cross-host holds and wildct's start it +bench/sandbox_round.sh ledger H18,H18w amend=Wildct H18's sandbox round, replicated on wildct +bench/zero-copy-models/** ledger H28,H28x,H28w amend=Wildct,Cross-host H28's probe, replicated cross-host and on wildct +bench/shared-prefix/** ledger H25,H25x,H25w,H59,H59d,H59w amend=Wildct,Cross-host,H59 H25's probe, replicated cross-host and on wildct; H59's prefix phase runs probe.py +bench/hetero-spec/** ledger H22,H34,H34x,H34w amend=Wildct,Cross-host H22 and H34's probes; h34.sh replicated cross-host and on wildct +bench/joule-pack/** ledger H21 amend=H21 H21's probe +bench/phase-steer/** ledger H20 amend=H20 H20's probe +bench/sku-model/** ledger H24 amend=H24 H24's probe and records +bench/tune-noise/** ledger H19 amend=H19 H19's runner +bench/uncore-phase/** ledger H29 amend=H29 H29's probe +bench/vs_matched.py ledger H30,H31,H32 amend= the H30-H32 matched-load comparison + +# --- k3d-gpu's own bench tooling (shipped by nfpm, or tests of what ships) ------------- +bench/vs.py ours k3d-gpu vs (nfpm ships it) +bench/llm_load.py ours k3d-gpu tune's load generator (nfpm ships it) +bench/energy.sh ours k3d-gpu tune's energy reader (nfpm ships it) +bench/agent_trace.py ours k3d-gpu capacity's trace replayer (nfpm ships it) +bench/agent_trace_gen.py ours k3d-gpu capacity's trace generator (nfpm ships it) +bench/test_agent_trace.py ours tests of the shipped trace replayer +bench/competitors.lock ours the competitor engines k3d-gpu vs runs (nfpm ships it) +bench/apb/** ours k3d-gpu capacity's apb.py and capacity.py (nfpm ships them), stage.py and their tests +oracle/** ours the capacity oracle's code, schema and tests (bundle.py, project.py, validate.py and share.py ship) diff --git a/scripts/images-lock b/scripts/images-lock index 39cacb5b..ff6c4c02 100755 --- a/scripts/images-lock +++ b/scripts/images-lock @@ -3,13 +3,22 @@ scripts/images-lock check offline: images.lock lists exactly the images the repo references, with the digests the repo pins - scripts/images-lock update network: resolve each digest from its registry, - read k3s's own image list, rewrite images.lock + scripts/images-lock update [--only FILE...] + network: resolve each digest from its registry, + read k3s's own image list, rewrite images.lock; a + pinned digest its tag no longer resolves to fails. + --only resolves and checks only the references FILEs + source (k3s-images.txt too when the Dockerfile is one) + and keeps every other entry as images.lock has it; a + reference another file adds, or pins to a digest the + lock does not hold, fails (name that file too) scripts/images-lock pins offline: every image reference in the repo is pinned - by digest (name:tag@sha256:...), except those that - images.unpinned lists, each with its reason; and every - NVIDIA apt package (nvidia-*, libnvidia-*) a Dockerfile - installs is pinned pkg=version + by digest under its tag (name:tag@sha256:...), except + those that images.unpinned lists, each with its reason + (a digest with no tag passes only in a ledger or + historical path of pins/classes: records keep what + ran); and every NVIDIA apt package (nvidia-*, + libnvidia-*) a Dockerfile installs is pinned pkg=version scripts/images-lock digest REF... network: print the registry's digest of each name:tag scripts/images-lock repin [--exclude GLOB]... [FILE...] @@ -18,7 +27,13 @@ repo, or in FILEs, and write the registry's digest; files whose repo-relative path matches a GLOB are left alone (the nightly passes '.github/workflows/*': its - token cannot push workflow files) + token cannot push workflow files), and so are the ledger + and historical paths of pins/classes and the files its + historical-ref lines name (a pending row's pins move + only by a ledger amendment, a finished run's never) + scripts/images-lock drift network: list the name:tag@sha256 pins (outside those + same paths) whose tag the registry now resolves to + another digest; writes nothing, exits 0 Sources of image references (the lock holds all of them and nothing else): - share/**/*.yml every `image:` line (the baked manifests and the @@ -36,8 +51,9 @@ Sources of image references (the lock holds all of them and nothing else): (coredns, local-path-provisioner, metrics-server, pause, ...): k3s itself starts them in the cluster -`pins` and `repin` read every tracked text file except prose (*.md, docs/) and -recorded data (research/, data/, oracle/raw/, */results/). An image reference is +`pins` and `repin` read every tracked text file except prose (*.md, docs/), +recorded data (research/, data/, oracle/raw/, */results/) and pins/classes (its +historical-ref strings quote finished runs' refs). An image reference is the value of an `image:` / "image": key, a `container:` key, a FROM line, a *IMAGE= / *IMG= assignment, a ${*IMAGE:-default}, a docker:// URL, or a Helm values mapping's sibling `repository:` (with `registry:`) and `tag:` keys in a *.yaml/*.yml @@ -57,7 +73,10 @@ Python standard library only. Options: --root DIR (default: this repo). """ import argparse +import email.utils import fnmatch +import importlib.machinery +import importlib.util import json import os import re @@ -83,6 +102,9 @@ ACCEPT = ", ".join([ ]) REGISTRY_HOSTS = {"docker.io": "registry-1.docker.io"} ALLOW_FILE = "images.unpinned" +# pins/classes names paths and quotes finished runs' refs exactly (historical-ref): its +# strings are not image uses, and repin must never move them. +CLASSES_FILE = "pins/classes" # One image reference: [registry[:port]/]path[:tag][@sha256:digest]. NAME_TAG = (r"(?:[a-z0-9][a-z0-9.-]*(?::[0-9]+)?/)?[a-z0-9][a-z0-9._-]*(?:/[a-z0-9][a-z0-9._-]*)*" r"(?::[A-Za-z0-9_][A-Za-z0-9._-]{0,127})?") @@ -118,6 +140,39 @@ from audit import parse_ref # noqa: E402 (one image-reference parser for the l import facts # noqa: E402 import schema_check # noqa: E402 +CLASSES_TOOL = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "hack", "pin-classes") +RETRY_STATUS = (429, 500, 502, 503, 504) +_classes = {} + + +def classes(root): + """(glob lines, historical-ref paths) of ROOT's pins/classes; none without the file.""" + if root not in _classes: + path = os.path.join(root, CLASSES_FILE) + if not os.path.isfile(path): + _classes[root] = ([], set()) + else: + if not os.path.isfile(CLASSES_TOOL): + raise ValueError("%s exists but %s does not" % (path, CLASSES_TOOL)) + loader = importlib.machinery.SourceFileLoader("pin_classes", CLASSES_TOOL) + pc = importlib.util.module_from_spec(importlib.util.spec_from_loader("pin_classes", loader)) + loader.exec_module(pc) + lines, refs = pc.load(path) + _classes[root] = (lines, {r.glob for r in refs}) + return _classes[root] + + +def pin_class(root, path): + """The kind of the first pins/classes line matching path (ours when none does).""" + lines, _ = classes(root) + return next((ln.kind for ln in lines if ln.matches(path)), "ours") + + +def frozen(root, path): + """True for a path `repin` and `drift` leave alone: a ledger or historical line's, + or a file a historical-ref line names.""" + return pin_class(root, path) in ("ledger", "historical") or path in classes(root)[1] + def dockerfile_froms(text): """The images the Dockerfile's FROM lines name, ARG defaults substituted.""" @@ -290,7 +345,7 @@ def scanned_files(root): paths += [os.path.relpath(os.path.join(dirpath, f), root).replace(os.sep, "/") for f in sorted(filenames)] return [p for p in sorted(paths) if not p.endswith(".md") and not p.startswith(SKIP_PREFIXES) and "/results/" not in "/" + p - and p not in ("images.lock", ALLOW_FILE)] + and p not in ("images.lock", ALLOW_FILE, CLASSES_FILE)] def read_text(root, path): @@ -393,6 +448,13 @@ def pins(root): if text is None: continue for line, ref, _ in image_refs(text, path): + if is_pinned(ref) and parse_ref(ref)[1] is None and pin_class(root, path) not in ("ledger", "historical"): + hits = allowed(allow, path, ref) + used.update(hits) + if not hits: + problems.append("%s:%d: %s has no tag (write name:tag@sha256:... with the tag the digest is, " + "or allow it in %s with a reason)" % (path, line, ref, ALLOW_FILE)) + continue if is_pinned(ref): continue hits = allowed(allow, path, ref) @@ -410,9 +472,11 @@ def pins(root): return problems -def repin(root, paths, exclude=()): +def repin(root, paths, exclude=(), write=True): """Rewrite each name:tag reference to name:tag@<the registry's digest>, except in - files whose repo-relative path matches an exclude glob; returns the changes.""" + files whose repo-relative path matches an exclude glob and in frozen() files. + Returns [(path, line, written text, new text, name:tag, old digest or None)]; + write=False changes nothing (drift).""" allow, digests, changes = read_allow(root), {}, [] def resolved(ref): @@ -422,17 +486,17 @@ def repin(root, paths, exclude=()): return digests[(name, tag)] for path in paths or scanned_files(root): - if any(fnmatch.fnmatchcase(path, g) for g in exclude): + if any(fnmatch.fnmatchcase(path, g) for g in exclude) or frozen(root, path): continue text = read_text(root, path) if text is None: continue - edits = {} # (start, end) -> new text + edits = {} # (start, end) -> (new text, name:tag, old digest) for m in PINNED_RE.finditer(text): if parse_ref(m.group("ref"))[1] is not None: d = resolved(m.group("ref")) if d != m.group("digest"): - edits[m.span("digest")] = d + edits[m.span("digest")] = (d, m.group("ref"), m.group("digest")) for _, ref, end in image_refs(text, path): name, tag, digest = parse_ref(ref) if end is None or tag is None: @@ -440,14 +504,16 @@ def repin(root, paths, exclude=()): if digest: # PINNED_RE moved name:tag@sha256 written as one; this is a values tag@sha256 d = resolved(ref) if d != digest: - edits[(end - len(digest), end)] = d + edits[(end - len(digest), end)] = (d, ref.rsplit("@", 1)[0], digest) elif not allowed(allow, path, ref): - edits[(end, end)] = "@" + resolved(ref) + edits[(end, end)] = ("@" + resolved(ref), ref, None) if not edits: continue - for (start, end), new in sorted(edits.items(), reverse=True): - changes.append("%s:%d: %s -> %s" % (path, text.count("\n", 0, start) + 1, text[start:end] or "(tag only)", new)) + for (start, end), (new, ref, old) in sorted(edits.items(), reverse=True): + changes.append((path, text.count("\n", 0, start) + 1, text[start:end] or "(tag only)", new, ref, old)) text = text[:start] + new + text[end:] + if not write: + continue full = os.path.join(root, path) with open(full + ".tmp", "w", encoding="utf-8") as f: f.write(text) @@ -456,17 +522,36 @@ def repin(root, paths, exclude=()): return changes +def retry_after(value): + """Seconds a Retry-After header asks for (delta-seconds or an HTTP date), or None.""" + if not value: + return None + try: + return max(0, int(value)) + except ValueError: + pass + try: + return max(0, int(email.utils.parsedate_to_datetime(value).timestamp() - time.time())) + except (TypeError, ValueError, OverflowError): + return None + + def http(req): - """urlopen with three tries on network errors (an HTTP status is not retried).""" - for attempt in range(3): + """urlopen with up to five attempts. A network error, an HTTP 429 or a 5xx is + retried after the Retry-After the reply names (capped at 120 s), else after 2, 4, + 8 and 16 s; any other HTTP status is raised at once.""" + for attempt in range(5): try: return urllib.request.urlopen(req, timeout=60) - except urllib.error.HTTPError: - raise + except urllib.error.HTTPError as e: + if e.code not in RETRY_STATUS or attempt == 4: + raise + wait = retry_after(e.headers.get("Retry-After")) except (urllib.error.URLError, TimeoutError): - if attempt == 2: + if attempt == 4: raise - time.sleep(3 * (attempt + 1)) + wait = None + time.sleep(min(120, 2 ** (attempt + 1) if wait is None else wait)) def registry_digest(name, tag): @@ -495,18 +580,49 @@ def registry_digest(name, tag): raise ValueError("%s:%s: registry refused the pull token" % (name, tag)) -def update(root, lock_path): - found, k3s_tag = collect(root) +def k3s_images(k3s_tag): + """The image references of k3s release k3s_tag's k3s-images.txt.""" with http(K3S_IMAGES_URL % urllib.parse.quote(k3s_tag.replace("-k3s", "+k3s"))) as r: - k3s_refs = [line.strip() for line in r.read().decode().splitlines() if line.strip()] - for ref in k3s_refs: + return [line.strip() for line in r.read().decode().splitlines() if line.strip()] + + +def update(root, lock_path, only=None): + """Rewrite images.lock from the registries. only: repo-relative files; then only + the references they source are resolved and checked (k3s-images.txt's when the + Dockerfile is one), and every other entry is kept as images.lock has it.""" + found, k3s_tag = collect(root) + k3s = k3s_source(k3s_tag) + named = {os.path.normpath(p).replace(os.sep, "/") for p in only or ()} + lock = read_lock(lock_path) if only is not None else None + old = {(e["name"], e["tag"]): e for e in lock["images"]} if lock else {} + if only is None or "Dockerfile" in named: + refs = [(ref, None) for ref in k3s_images(k3s_tag)] + else: + if lock["k3s_tag"] != k3s_tag: + raise ValueError("the Dockerfile's K3S_TAG is %s, images.lock is for %s: name the Dockerfile in --only" + % (k3s_tag, lock["k3s_tag"])) + refs = [(label(e["name"], e["tag"]), e["digest"]) for e in lock["images"] if k3s in e["sources"]] + for ref, locked in refs: name, tag, digest = parse_ref(ref) - e = found.setdefault((name, tag), {"digest": digest, "sources": set()}) - e["sources"].add(k3s_source(k3s_tag)) + e = found.setdefault((name, tag), {"digest": digest or locked, "sources": set()}) + e["sources"].add(k3s) images = [] for (name, tag), e in sorted(found.items(), key=lambda kv: (kv[0][0], kv[0][1] or "")): digest = e["digest"] - if digest is None: + files = {s.split(" ", 1)[0] for s in e["sources"]} | ({"Dockerfile"} if k3s in e["sources"] else set()) + if only is not None and not files & named: + # Not a file --only names: kept as images.lock has it, unresolved. A + # reference the lock lacks, or pins another digest, is a change to that + # file, so its file must be named. + if (name, tag) not in old: + raise ValueError("%s (from %s): not in images.lock; name its file in --only" + % (label(name, tag), ", ".join(sorted(e["sources"])))) + if digest is not None and digest != old[(name, tag)]["digest"]: + raise ValueError("%s (from %s): pins %s, images.lock has %s; name its file in --only" + % (label(name, tag), ", ".join(sorted(e["sources"])), digest, + old[(name, tag)]["digest"])) + digest = old[(name, tag)]["digest"] + elif digest is None: digest = registry_digest(name, tag) elif tag is not None and registry_digest(name, tag) != digest: raise ValueError("%s:%s: the repo pins %s but the registry serves %s" @@ -522,20 +638,29 @@ def update(root, lock_path): def main(argv): here = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ap = argparse.ArgumentParser(prog="images-lock", description=__doc__.split("\n")[0]) - ap.add_argument("command", choices=("check", "update", "pins", "repin", "digest")) + ap.add_argument("command", choices=("check", "update", "pins", "repin", "drift", "digest")) ap.add_argument("files", nargs="*", help="repin: only these repo-relative files; digest: the references") ap.add_argument("--root", default=here) ap.add_argument("--exclude", action="append", default=[], metavar="GLOB", help="repin: leave files whose repo-relative path matches GLOB alone") + ap.add_argument("--only", nargs="+", metavar="FILE", + help="update: resolve and check only the references these repo-relative files source") args = ap.parse_args(argv[1:]) if args.files and args.command not in ("repin", "digest"): ap.error("only repin and digest take arguments") if args.exclude and args.command != "repin": ap.error("only repin takes --exclude") + if args.only and args.command != "update": + ap.error("only update takes --only") lock_path = os.path.join(args.root, "images.lock") try: if args.command == "update": - update(args.root, lock_path) + update(args.root, lock_path, args.only) + return 0 + if args.command == "drift": + moved = [c for c in repin(args.root, [], write=False) if c[5]] + for path, line, _, new, ref, old in sorted(moved): + print("%s:%d: %s %s -> %s" % (path, line, ref, old, new)) return 0 if args.command == "digest": for ref in args.files: @@ -545,8 +670,8 @@ def main(argv): print(registry_digest(name, tag)) return 0 if args.command == "repin": - for c in repin(args.root, args.files, args.exclude): - print(c) + for path, line, was, new, _, _ in repin(args.root, args.files, args.exclude): + print("%s:%d: %s -> %s" % (path, line, was, new)) return 0 problems = pins(args.root) if args.command == "pins" else check(args.root, lock_path) except (ValueError, OSError, KeyError) as e: diff --git a/scripts/k3d-gpu b/scripts/k3d-gpu index 4134a048..4cad53bd 100755 --- a/scripts/k3d-gpu +++ b/scripts/k3d-gpu @@ -58,9 +58,10 @@ # fit size a model to what this host has free now: placement, context x seqs, engine # memory flags, pod memory limit, or why it cannot fit; --explain prints the arithmetic # audit posture checks: unauthenticated listeners off loopback, images not in images.lock -# sandbox up|down|status +# sandbox up [--adopt]|down|status +# kubernetes-sigs/agent-sandbox with a warm pool of sandboxes pinned to E-cores; +# --adopt keeps another installer's controller and adds only the E-core profile # model-share --image REF --agent-image REF [--replicas N] [--name NAME] -# kubernetes-sigs/agent-sandbox with a warm pool of sandboxes pinned to E-cores # tune run|show|space|env|fingerprint|reset: search the OVMS agent's serving config on # this host; keep a winner only if it beats the default; agent up applies it # capacity agents per box: closed-loop agent ramp against the cluster's chat endpoint @@ -286,9 +287,15 @@ Usage: k3d-gpu <command> Posture checks of the running cluster: fails on an unauthenticated listener off loopback, an image not pinned in images.lock, a runtime pull, a key file readable by others, or Open WebUI without sign-in - sandbox up|down|status + sandbox up [--adopt]|down|status Install kubernetes-sigs/agent-sandbox with a warm pool of sandboxes on E-cores (k3d-gpu.io/cores=efficient), check where they run, or remove it. + up stops when another installer's controller (Intel's Enterprise Agent + Toolkit installs v0.5.0 in agent-sandbox-system) or CRDs are installed; + up --adopt keeps that controller and adds only the E-core profile (its + CRDs must serve v1beta1). down removes the profile, and removes the namespace + agent-sandbox-system, the CRDs and the controller only when k3d-gpu + installed them and no other installer's sandboxes or pods use them. up --remote [URL]: egress only to the endpoint's addresses and port (no DNS; on a CDN those addresses also serve its other sites), and endpoint in the sandbox's Secret k3d-gpu-llm, which sandbox code can @@ -5959,6 +5966,16 @@ amx_probe() { # Image refs are pasted into the pod overrides JSON, so only accept reference characters. valid_image() { [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._/:@+-]*$ ]]; } +# image_id REF: name@sha256:<digest> for a REF that carries a digest, its tag dropped +# (the digest is the image), else REF. Cache keys (tune, autoquant) use it, so a tag +# written next to a pinned digest does not invalidate them. +image_id() { + local name=${1%@*} + [[ "$1" == *@sha256:* ]] || { printf '%s\n' "$1"; return; } + [[ "${name##*/}" != *:* ]] || name=${name%:*} + printf '%s@%s\n' "${name}" "${1##*@}" +} + cmd_test() { valid_image "${TEST_IMAGE}" || err "K3D_GPU_TEST_IMAGE '${TEST_IMAGE}' is not a valid image reference" valid_image "${DEVICE_TEST_IMAGE}" || err "K3D_GPU_DEVICE_TEST_IMAGE '${DEVICE_TEST_IMAGE}' is not a valid image reference" @@ -6632,13 +6649,111 @@ SANDBOX_CRDS=( crd/sandboxclaims.extensions.agents.x-k8s.io crd/sandboxwarmpools.extensions.agents.x-k8s.io ) +SANDBOX_ADOPT="" # sandbox up --adopt: use the agent-sandbox controller already installed + +# Ownership. Other installers put agent-sandbox in the same namespace (Intel's EAT +# installs v0.5.0 there, with its own warm pool and router). The controller is k3d-gpu's +# when its Deployment carries app.kubernetes.io/managed-by=k3d-gpu (`sandbox up` sets it +# on the Deployment, the Namespace and the CRDs after an install), or when it runs the +# controller image pinned in ${SANDBOX_MANIFEST} or one an earlier k3d-gpu release +# installed before the label (SANDBOX_UNLABELLED_IMAGES), and no other installer marked +# it (another managed-by value, or a Helm release annotation). +SANDBOX_OWNER_LABEL="app.kubernetes.io/managed-by=k3d-gpu" +SANDBOX_UNLABELLED_IMAGES=( + registry.k8s.io/agent-sandbox/agent-sandbox-controller:v1.0.7@sha256:f254384c24605436ce35d7077427559e24bda8f5691cff5a54a4c2e030d81083 +) + +# sandbox_controller: "<images>|<managed-by>|<Helm release>" of the controller +# Deployment, nothing when there is none. Fails when kubectl cannot read it. +sandbox_controller() { + kubectl -n agent-sandbox-system get deployment agent-sandbox-controller --ignore-not-found \ + -o jsonpath='{.spec.template.spec.containers[*].image}{"|"}{.metadata.labels.app\.kubernetes\.io/managed-by}{"|"}{.metadata.annotations.meta\.helm\.sh/release-name}' +} + +# sandbox_whose <sandbox_controller line>: absent, ours or foreign. +sandbox_whose() { + local images managed helm + [ -n "$1" ] || { echo absent; return; } + IFS='|' read -r images managed helm <<< "$1" + if [ -n "${helm}" ]; then echo foreign + elif [ "${managed}" = k3d-gpu ]; then echo ours + elif [ -n "${managed}" ]; then echo foreign + elif printf '%s\n' "$(sandbox_pinned_image)" "${SANDBOX_UNLABELLED_IMAGES[@]}" | grep -Fx -- "${images}" >/dev/null; then echo ours + else echo foreign + fi +} + +# sandbox_pinned_image: the controller image ${SANDBOX_MANIFEST} installs. +sandbox_pinned_image() { + sed -n 's/^[[:space:]]*image:[[:space:]]*\([^[:space:]]*\/agent-sandbox-controller[:@][^[:space:]]*\).*$/\1/p' "${SANDBOX_MANIFEST}" 2>/dev/null | head -1 +} + +# sandbox_marks <managed-by> <Helm release>: "managed-by M, Helm release R", the set ones. +sandbox_marks() { + local marks="" + [ -z "$1" ] || marks="managed-by $1" + [ -z "$2" ] || marks="${marks:+${marks}, }Helm release $2" + echo "${marks}" +} + +# sandbox_describe <sandbox_controller line>: "<images> (managed-by M, Helm release R)". +sandbox_describe() { + local images managed helm marks + IFS='|' read -r images managed helm <<< "$1" + marks=$(sandbox_marks "${managed}" "${helm}") + echo "${images:-no image}${marks:+ (${marks})}" +} + +# sandbox_crds: "<name>|<served versions>|<managed-by>|<Helm release>" per installed CRD. +sandbox_crds() { + kubectl get crd "${SANDBOX_CRDS[@]#crd/}" --ignore-not-found \ + -o jsonpath='{range .items[*]}{.metadata.name}{"|"}{.spec.versions[?(@.served==true)].name}{"|"}{.metadata.labels.app\.kubernetes\.io/managed-by}{"|"}{.metadata.annotations.meta\.helm\.sh/release-name}{"\n"}{end}' +} + +# sandbox_foreign_crds <sandbox_crds lines>: "CRD <name> is another installer's (<marks>)" +# for each CRD with a Helm release or a managed-by other than k3d-gpu. +sandbox_foreign_crds() { + local name managed helm + while IFS='|' read -r name _ managed helm; do + [ -n "${name}" ] || continue + if [ -n "${helm}" ] || { [ -n "${managed}" ] && [ "${managed}" != k3d-gpu ]; }; then + echo "CRD ${name} is another installer's ($(sandbox_marks "${managed}" "${helm}"))" + fi + done <<< "$1" +} sandbox_up() { require kubectl - local f ready="" deadline profile fields base model origin + local f ready="" deadline profile fields base model origin ctl whose crds theirs found crd served for f in "${SANDBOX_MANIFEST}" "${SANDBOX_PROFILE_MANIFEST}"; do [ -f "${f}" ] || err "${f} not found — set K3D_GPU_SHARE, K3D_GPU_SANDBOX or K3D_GPU_SANDBOX_PROFILE" done + ctl=$(sandbox_controller) || err "could not read deployment agent-sandbox-system/agent-sandbox-controller; nothing applied" + whose=$(sandbox_whose "${ctl}") + crds=$(sandbox_crds) || err "could not read the agent-sandbox CRDs; nothing applied" + theirs=$(sandbox_foreign_crds "${crds}") + if [ -n "${SANDBOX_ADOPT}" ]; then + [ "${whose}" != absent ] || err "no agent-sandbox controller to adopt in agent-sandbox-system; 'k3d-gpu sandbox up' installs agent-sandbox ${SANDBOX_VERSION}" + # The profile is extensions.agents.x-k8s.io/v1beta1. + for crd in sandboxtemplates.extensions.agents.x-k8s.io sandboxwarmpools.extensions.agents.x-k8s.io; do + served=$(awk -F'|' -v n="${crd}" '$1 == n {print $2}' <<< "${crds}") + [[ " ${served} " == *" v1beta1 "* ]] \ + || err "CRD ${crd} does not serve extensions.agents.x-k8s.io/v1beta1 (served: ${served:-none}); the profile needs it, so nothing applied" + done + elif [ "${whose}" = foreign ] || [ -n "${theirs}" ]; then + # Applying the manifest would replace that installer's controller or CRDs, and + # the label would claim them for k3d-gpu. + found="" + [ "${whose}" != foreign ] || found="agent-sandbox-system/agent-sandbox-controller runs $(sandbox_describe "${ctl}"), not k3d-gpu's ${SANDBOX_VERSION}" + if [ -n "${found}" ] && [ -n "${theirs}" ]; then found+=$'\n'; fi + found+="${theirs}" + [ "${whose}" != absent ] || err "${found} +another installer put agent-sandbox there, so nothing was applied: remove it with that installer, then run 'k3d-gpu sandbox up'" + err "${found} +another installer put agent-sandbox there, so nothing was applied. Either: + k3d-gpu sandbox up --adopt keep that controller and add only the E-core profile (its CRDs must serve v1beta1) + or remove that installer's agent-sandbox with that installer, then run 'k3d-gpu sandbox up'" + fi if [ -n "${REMOTE}" ]; then # Remote: egress is the endpoint's host:port only (no DNS; hostAliases pin # the name), and the sandbox reads the endpoint from its own Secret copy. @@ -6648,11 +6763,20 @@ sandbox_up() { || err "could not pin the sandbox egress to ${base}" info "remote endpoint ${base} (from ${origin}): sandbox egress is ${base#*://} only, no DNS" fi - info "Installing agent-sandbox ${SANDBOX_VERSION} (${SANDBOX_MANIFEST})..." - kubectl apply -f "${SANDBOX_MANIFEST}" + if [ -n "${SANDBOX_ADOPT}" ]; then + info "adopting agent-sandbox-system/agent-sandbox-controller ($(sandbox_describe "${ctl}")): no controller manifest applied" + else + info "Installing agent-sandbox ${SANDBOX_VERSION} (${SANDBOX_MANIFEST})..." + kubectl apply -f "${SANDBOX_MANIFEST}" + fi # The profile's SandboxTemplate and SandboxWarmPool need their CRDs served first. kubectl wait --for=condition=Established "${SANDBOX_CRDS[@]}" --timeout=60s kubectl -n agent-sandbox-system rollout status deployment/agent-sandbox-controller --timeout=180s + if [ -z "${SANDBOX_ADOPT}" ]; then + kubectl -n agent-sandbox-system label --overwrite deployment/agent-sandbox-controller \ + namespace/agent-sandbox-system "${SANDBOX_CRDS[@]}" "${SANDBOX_OWNER_LABEL}" >/dev/null \ + || warn "could not label agent-sandbox ${SANDBOX_OWNER_LABEL}; k3d-gpu still knows its controller by the pinned image" + fi # The Secret exists before the warm pool's pods, which read it at start. [ -z "${REMOTE}" ] || remote_secret_apply "${SANDBOX_NS}" "${base}" "${model}" info "Applying the E-core sandbox profile (${SANDBOX_PROFILE_MANIFEST})..." @@ -6716,27 +6840,66 @@ sandbox_status() { [ "${fail}" -eq 0 ] || err "sandbox pods are not pinned to E-cores, or the endpoint moved" } +# Deletes the profile, then the namespace agent-sandbox-system, the CRDs and the +# controller, unless another installer owns or uses them: deleting a CRD deletes every +# object of its kind in every namespace. sandbox_down() { require kubectl - info "Removing the sandbox profile and agent-sandbox ${SANDBOX_VERSION}..." + local ctl crds kinds="" crd name line objs pods kept=() + info "Removing the sandbox profile (${SANDBOX_PROFILE_MANIFEST})..." # Profile first, while its CRDs still exist; deleting the CRDs removes every sandbox. kubectl delete -f "${SANDBOX_PROFILE_MANIFEST}" --ignore-not-found \ || warn "profile objects not removed (CRDs already gone?)" + ctl=$(sandbox_controller) \ + || err "could not read deployment agent-sandbox-system/agent-sandbox-controller: kept namespace agent-sandbox-system, the agent-sandbox CRDs and the controller" + [ "$(sandbox_whose "${ctl}")" != foreign ] || kept+=("the controller runs $(sandbox_describe "${ctl}"), not k3d-gpu's ${SANDBOX_VERSION}") + crds=$(sandbox_crds) || err "could not read the agent-sandbox CRDs: kept namespace agent-sandbox-system, the CRDs and the controller" + while IFS='|' read -r name _; do + [ -z "${name}" ] || kinds="${kinds:+${kinds},}${name}" + done <<< "${crds}" + while read -r line; do + [ -z "${line}" ] || kept+=("${line}") + done <<< "$(sandbox_foreign_crds "${crds}")" + if [ -n "${kinds}" ]; then + objs=$(kubectl get "${kinds}" -A -o jsonpath='{range .items[*]}{.kind}{" "}{.metadata.namespace}{"/"}{.metadata.name}{"\n"}{end}') \ + || err "could not list the sandbox objects: kept namespace agent-sandbox-system, the agent-sandbox CRDs and the controller" + while read -r crd name; do + if [ -n "${name}" ] && [ "${name%%/*}" != "${SANDBOX_NS}" ]; then kept+=("${crd} ${name} uses the CRDs"); fi + done <<< "${objs}" + fi + pods=$(kubectl -n agent-sandbox-system get pods -l 'app!=agent-sandbox-controller' -o name) \ + || err "could not list the pods in agent-sandbox-system: kept the namespace, the agent-sandbox CRDs and the controller" + while read -r name; do + [ -z "${name}" ] || kept+=("${name} in agent-sandbox-system is not k3d-gpu's") + done <<< "${pods}" + if [ "${#kept[@]}" -gt 0 ]; then + info "Kept namespace agent-sandbox-system, the agent-sandbox CRDs and the controller:" + printf ' %s\n' "${kept[@]}" + echo " remove them with the installer that owns them" + return 0 + fi + info "Removing agent-sandbox ${SANDBOX_VERSION}: namespace agent-sandbox-system, the CRDs and the controller (${SANDBOX_MANIFEST})..." kubectl delete -f "${SANDBOX_MANIFEST}" --ignore-not-found } cmd_sandbox() { - local sub=${1:-} + local sub=${1:-} a [ $# -eq 0 ] || shift remote_parse "$@" - [ "${#REMOTE_REST[@]}" -eq 0 ] || err "unknown sandbox option: ${REMOTE_REST[0]}" + for a in ${REMOTE_REST[@]+"${REMOTE_REST[@]}"}; do + case "${a}" in + --adopt) SANDBOX_ADOPT=1 ;; + *) err "unknown sandbox option: ${a}" ;; + esac + done + [ -z "${SANDBOX_ADOPT}" ] || [ "${sub}" = up ] || err "--adopt goes with 'sandbox up'" [ -z "${REMOTE}" ] || [ "${sub}" = up ] || err "--remote goes with 'sandbox up'" [ -n "${REMOTE}" ] || [ "${#REMOTE_ARGS[@]}" -eq 0 ] || err "--model and --api-key-file go with --remote" case "${sub}" in up) sandbox_up ;; down) sandbox_down ;; status) sandbox_status ;; - *) err "usage: k3d-gpu sandbox up|down|status (up --remote [URL] [--api-key-file F]: egress only to that endpoint)" ;; + *) err "usage: k3d-gpu sandbox up|down|status (up --adopt: keep another installer's agent-sandbox controller, add only the profile; up --remote [URL] [--api-key-file F]: egress only to that endpoint)" ;; esac } @@ -7547,7 +7710,7 @@ AGENT_SEL="" # "<device> <resource>", set by agent_select # llama.cpp server on Vulkan, the engine plan() may pick for a GPU cell other # than OVMS: ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960, the multi-arch # index built from release v0.8.0 (tag b11960, commit 8642541b255bfb5dc39cd351a178be09013a55d6). -LLAMA_VULKAN_IMAGE="${K3D_GPU_LLAMA_VULKAN_IMAGE:-ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e}" +LLAMA_VULKAN_IMAGE="${K3D_GPU_LLAMA_VULKAN_IMAGE:-ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e}" # The llama.cpp agent backend: release v0.8.0 (tag b11960, commit # 8642541b255bfb5dc39cd351a178be09013a55d6), the latest release on 2026-10-28; its image # tags are server-intel-b11960 (SYCL, Level Zero and compute-runtime 26.18 inside) for an @@ -7559,7 +7722,7 @@ LLAMA_FULL_IMAGE="${K3D_GPU_LLAMA_FULL_IMAGE:-ghcr.io/ggml-org/llama.cpp:full-b1 AGENT_STORAGE="${K3D_GPU_AGENT_STORAGE:-20Gi}" AGENT_TIMEOUT="${K3D_GPU_AGENT_TIMEOUT:-1800}" # seconds: first run downloads the model # openvino/model_server:2026.4.1-gpu (CPU, GPU and NPU plugins, compute-runtime inside). -AGENT_OVMS_IMAGE="openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" +AGENT_OVMS_IMAGE="openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" # Image, model, parsers and API of AGENT_BACKEND (on a Gaudi when AGENT_HPU is set). agent_config() { @@ -7572,13 +7735,13 @@ agent_config() { # (PyTorch 2.13.0), the release of the baked gaudi-device-plugin. Tag # 1.24.2-481 of vault.habana.ai/gaudi-docker/1.24.2/ubuntu24.04/habanalabs/ # vllm-0.30.0-ptupstream-2.13.0, pulled anonymously. - AGENT_DEFAULT_IMAGE="vault.habana.ai/gaudi-docker/1.24.2/ubuntu24.04/habanalabs/vllm-0.30.0-ptupstream-2.13.0@sha256:5d064465d751a73a729e7ecdc32c0ba5de0df1d305debb60a3d9a85b4fd3a967" + AGENT_DEFAULT_IMAGE="vault.habana.ai/gaudi-docker/1.24.2/ubuntu24.04/habanalabs/vllm-0.30.0-ptupstream-2.13.0:1.24.2-481@sha256:5d064465d751a73a729e7ecdc32c0ba5de0df1d305debb60a3d9a85b4fd3a967" elif [ "${AGENT_BACKEND}" = vllm ]; then # vllm/vllm-openai-xpu:v0.32.0 (Level Zero 1.32 and compute-runtime 26.27 inside). - AGENT_DEFAULT_IMAGE="vllm/vllm-openai-xpu@sha256:20509fabe50dda62c09c0786d1a6effa299d3dbdddcc49331b93e65144652a8c" + AGENT_DEFAULT_IMAGE="vllm/vllm-openai-xpu:v0.32.0@sha256:20509fabe50dda62c09c0786d1a6effa299d3dbdddcc49331b93e65144652a8c" else # intel/llm-scaler-vllm:1.5 = 0.26.0-b2 (vLLM 0.26 baseline, compute-runtime 26.14). - AGENT_DEFAULT_IMAGE="intel/llm-scaler-vllm@sha256:52218ad85513ab6686d4c090c83c2bd8c5b02423c63aa4dabd41837fe641fe3b" + AGENT_DEFAULT_IMAGE="intel/llm-scaler-vllm:1.5@sha256:52218ad85513ab6686d4c090c83c2bd8c5b02423c63aa4dabd41837fe641fe3b" fi # Qwen3-4B is 8 GB in float16/bfloat16: it fits a 12 GB Arc B580 with room # for the KV cache. @@ -8840,7 +9003,7 @@ AGENT_VLM_DEFAULT_MODEL="OpenVINO/Qwen3-VL-4B-Instruct-int4-ov" AGENT_VLM_NPU_MODEL="OpenVINO/Phi-3.5-vision-instruct-fp16-ov" AGENT_STT_MODEL="${K3D_GPU_AGENT_STT_MODEL:-OpenVINO/whisper-large-v3-turbo-fp16-ov}" # intel/dlstreamer:2026.2.0-ubuntu24 (GStreamer va plugin, OpenVINO GPU/NPU runtimes inside). -AGENT_VIDEO_IMAGE="${K3D_GPU_AGENT_VIDEO_IMAGE:-intel/dlstreamer@sha256:3848efe52c047cf6b5961eb070deaf9eca53bd5328f0d819fbb68ad56c65fb19}" +AGENT_VIDEO_IMAGE="${K3D_GPU_AGENT_VIDEO_IMAGE:-intel/dlstreamer:2026.2.0-ubuntu24@sha256:3848efe52c047cf6b5961eb070deaf9eca53bd5328f0d819fbb68ad56c65fb19}" # Open Model Zoo person-vehicle-bike-detection-2004 FP16 IR and the DL Streamer # sample clip, pinned by URL and checksum. AGENT_VIDEO_MODEL_URL="https://storage.openvinotoolkit.org/repositories/open_model_zoo/2023.0/models_bin/1/person-vehicle-bike-detection-2004/FP16/person-vehicle-bike-detection-2004" @@ -9815,7 +9978,7 @@ tune_fingerprint_inputs() { echo "intel_gpu=${INTEL_DRIVERS[*]+${INTEL_DRIVERS[*]}}" echo "npu=${NPU_IDS[*]+${NPU_IDS[*]}}" echo "kernel=$(kernel_release | cut -d. -f1-2)" - echo "agent_image=${K3D_GPU_AGENT_IMAGE:-${AGENT_OVMS_IMAGE}}" + echo "agent_image=${K3D_GPU_AGENT_IMAGE:-$(image_id "${AGENT_OVMS_IMAGE}")}" echo "agent_model=${TUNE_MODEL}" } @@ -11515,11 +11678,11 @@ autoquant_run() { [ -f "${dir}/autoquant.py" ] || return 1 command -v python3 >/dev/null 2>&1 || return 1 if [ -n "${facts}" ]; then - python3 "${dir}/autoquant.py" "$@" --cache "$(autoquant_cache)" --runtime "${AGENT_OVMS_IMAGE}" < "${facts}" + python3 "${dir}/autoquant.py" "$@" --cache "$(autoquant_cache)" --runtime "$(image_id "${AGENT_OVMS_IMAGE}")" < "${facts}" else mapfile -t host_args < <(autoquant_host_args) facts_json "${dir}" | python3 "${dir}/autoquant.py" "$@" --cache "$(autoquant_cache)" \ - --runtime "${AGENT_OVMS_IMAGE}" ${host_args[@]+"${host_args[@]}"} + --runtime "$(image_id "${AGENT_OVMS_IMAGE}")" ${host_args[@]+"${host_args[@]}"} fi } diff --git a/share/agent-chat-ui.yml b/share/agent-chat-ui.yml index 4284e599..59740508 100644 --- a/share/agent-chat-ui.yml +++ b/share/agent-chat-ui.yml @@ -62,7 +62,7 @@ spec: containers: - name: open-webui # ghcr.io/open-webui/open-webui:v0.12.2 - image: ghcr.io/open-webui/open-webui@sha256:10e6a78ba2d172493460a9e8a92c496aaa7a64dc4d26d622a2e95e0c736081c9 + image: ghcr.io/open-webui/open-webui:v0.12.2@sha256:10e6a78ba2d172493460a9e8a92c496aaa7a64dc4d26d622a2e95e0c736081c9 imagePullPolicy: IfNotPresent # The image runs as root and writes under /app/backend at runtime. securityContext: diff --git a/share/telemetry/intel-gpu-exporter.yml b/share/telemetry/intel-gpu-exporter.yml index 8d387522..bc880048 100644 --- a/share/telemetry/intel-gpu-exporter.yml +++ b/share/telemetry/intel-gpu-exporter.yml @@ -91,7 +91,7 @@ spec: type: RuntimeDefault containers: - name: drm-exporter - image: "ghcr.io/home-operations/drm-exporter@sha256:9b5424634b74f0c6905109fc46f90a7f569ab7bde1211d3ac5f9c32a17895e60" + image: "ghcr.io/home-operations/drm-exporter:0.3.4@sha256:9b5424634b74f0c6905109fc46f90a7f569ab7bde1211d3ac5f9c32a17895e60" imagePullPolicy: IfNotPresent securityContext: privileged: true diff --git a/tests/agent_kubeconform.sh b/tests/agent_kubeconform.sh index 285bc391..c9ca190d 100755 --- a/tests/agent_kubeconform.sh +++ b/tests/agent_kubeconform.sh @@ -10,8 +10,9 @@ set -euo pipefail K3D_GPU="$(cd "$(dirname "$0")/.." && pwd)/scripts/k3d-gpu" # Kubernetes version of the k3s node image. K8S_VERSION=$(sed -n 's/^ARG K3S_TAG="v\([0-9.]*\)-k3s.*/\1/p' "$(dirname "$0")/../Dockerfile") -# ghcr.io/yannh/kubeconform:v0.8.0 -KUBECONFORM_IMAGE="ghcr.io/yannh/kubeconform@sha256:faffaf43f95aa6425306e1ab8d6fcad72acb9049158f38e574c085ea1ec0f64e" +# The pinned kubeconform image, where the CI jobs read it too. +KUBECONFORM_IMAGE=$(sed -n 's/^KUBECONFORM_IMAGE=//p' "$(dirname "$0")/../hack/ci-tools.env") +: "${KUBECONFORM_IMAGE:?hack/ci-tools.env has no KUBECONFORM_IMAGE}" WORK=$(mktemp -d) # A measured autoquant pick on this dev box (EXPERIMENTS.md H27) must not leak into goldens. diff --git a/tests/audit_test.sh b/tests/audit_test.sh index b6d4a900..48697099 100755 --- a/tests/audit_test.sh +++ b/tests/audit_test.sh @@ -82,8 +82,9 @@ lock_drift "a bumped image under share/telemetry" \ lock_drift "a bumped plain image pin in the launcher" \ "not in images.lock: docker.io/openvino/model_server:2099.1.0-gpu (from scripts/k3d-gpu NPU_UMD_IMAGE)" \ "sed -i 's|^NPU_UMD_IMAGE=.*|NPU_UMD_IMAGE=\"openvino/model_server:2099.1.0-gpu\"|' scripts/k3d-gpu" -lock_drift "a moved backend digest in the launcher" "vllm/vllm-openai-xpu: the repo pins sha256:$(printf 'e%.0s' {1..64})" \ - "sed -i 's|vllm/vllm-openai-xpu@sha256:[0-9a-f]*|vllm/vllm-openai-xpu@sha256:$(printf 'e%.0s' {1..64})|' scripts/k3d-gpu" +XPU_TAG=$(sed -n 's|.*AGENT_DEFAULT_IMAGE="vllm/vllm-openai-xpu:\([^@]*\)@.*|\1|p' "${REPO}/scripts/k3d-gpu") +lock_drift "a moved backend digest in the launcher" "vllm/vllm-openai-xpu:${XPU_TAG}: the repo pins sha256:$(printf 'e%.0s' {1..64})" \ + "sed -i 's|\(vllm/vllm-openai-xpu:[^@]*\)@sha256:[0-9a-f]*|\1@sha256:$(printf 'e%.0s' {1..64})|' scripts/k3d-gpu" lock_drift "a changed pinned digest" "the repo pins sha256:$(printf 'f%.0s' {1..64})" \ "sed -i 's/node-feature-discovery:v0.20.1@sha256:[0-9a-f]*/node-feature-discovery:v0.20.1@sha256:$(printf 'f%.0s' {1..64})/' share/node-feature-discovery.yml" lock_drift "a k3s bump" "images.lock is for k3s v1.37.1-k3s1, the Dockerfile pins v1.38.0-k3s1" \ @@ -162,7 +163,7 @@ f = "crictl-k3d-gpu-server-0.json"; d = json.load(open(f)) d["images"].append({"id": "sha256:" + "9" * 64, "repoTags": TAGS, "repoDigests": DIGESTS, "pinned": False}) json.dump(d, open(f, "w"))' img_edit() { local s=${PY_IMG/TAGS/$1}; echo "${s/DIGESTS/$2}"; } -OWUI_DIGEST=$(sed -nE 's/^ +image: ghcr\.io\/open-webui\/open-webui@(sha256:[0-9a-f]{64})$/\1/p' "${REPO}/share/agent-chat-ui.yml") +OWUI_DIGEST=$(sed -nE 's/^ +image: ghcr\.io\/open-webui\/open-webui(:[^@]+)?@(sha256:[0-9a-f]{64})$/\2/p' "${REPO}/share/agent-chat-ui.yml") chat_ui_edit() { # chat_ui_edit <WEBUI_AUTH value as a Python literal> printf '%s\n' "$(img_edit '[]' "[\"ghcr.io/open-webui/open-webui@${OWUI_DIGEST}\"]")" \ 'w = json.load(open("workloads.json"))' \ diff --git a/tests/auto_release_cas_test.sh b/tests/auto_release_cas_test.sh index 567995ee..ff9e5d85 100755 --- a/tests/auto_release_cas_test.sh +++ b/tests/auto_release_cas_test.sh @@ -36,7 +36,9 @@ setup() { git clone -q "file://$D/origin.git" "$D/seed" 2>/dev/null ( cd "$D/seed" || exit 1 git checkout -q -b main - mkdir -p scripts packaging/aur Formula share console + mkdir -p scripts packaging/aur Formula share console hack + # The shipped paths a push run releases for: the repo's own list. + cp "$ROOT/hack/release-paths" hack/release-paths # Stand-in for console/model_opt.py ledger-rules: the JSON names the ledger's line count. cat > console/model_opt.py <<'PY' import sys @@ -57,7 +59,7 @@ PY write() { ( cd "$D/seed" || exit 1 git fetch -q origin; git reset -q --hard origin/main - echo "x $RANDOM" >> "$1"; git add "$1"; git commit -q -m "$2"; git push -q origin HEAD:main ) + mkdir -p "$(dirname "$1")"; echo "x $RANDOM" >> "$1"; git add "$1"; git commit -q -m "$2"; git push -q origin HEAD:main ) } runner() { git clone -q "file://$D/origin.git" "$D/$1" 2>/dev/null; } # release DIR EVENT [HOOK]: run the release body in a runner checkout. @@ -205,6 +207,34 @@ got=$(o show "$m:console/ledger-rules.json" 2>&1) c=1; [ "$rc" -eq 0 ] && [ "$(tagsha aur-v0.1.6)" = "$m" ] && [ "$got" = "$want" ] && c=0 check ledger_rules_regenerated $c "rc=$rc got=$got want=$want" +# shipped_paths: a push run (and a mode=changed dispatch, which runs as a push) +# releases for a change to any path hack/release-paths lists: console/, a shipped +# bench or oracle file; and not for the ledger or a bench file nothing ships. +for c in "console/plan.py:release" "bench/vs.py:release" "oracle/schema/bundle-v1.json:release" \ + "images.lock:release" "EXPERIMENTS.md:none" "bench/h54/run.sh:none" "docs/x.md:none"; do + f=${c%%:*}; want=${c#*:} + setup "shipped_${f//\//_}"; write "$f" "change: $f"; before=$(main); runner r; release r push; rc=$? + if [ "$want" = release ]; then + c=1; [ "$rc" -eq 0 ] && [ "$(tagsha aur-v0.1.6)" = "$(main)" ] && o merge-base --is-ancestor "$before" "$(main)" && c=0 + else + c=1; [ "$rc" -eq 0 ] && [ "$(main)" = "$before" ] && [ "$(tags)" = "aur-v0.1.5 " ] && grep -q 'nothing to release' "$D/r.log" && c=0 + fi + check "shipped_paths: $f -> $want" $c "rc=$rc tags=$(tags) $(tail -1 "$D/r.log")" +done + +# release_paths_read: the path filter is read from main's hack/release-paths, not +# built in: listing docs/** makes a docs-only change releasable. +setup release_paths_read +( cd "$D/seed" && echo 'docs/**' >> hack/release-paths && git commit -qam "ci: ship docs [skip release]" && git push -q origin HEAD:main ) +write docs/x.md "docs: x"; runner r; release r push; rc=$? +c=1; [ "$rc" -eq 0 ] && [ "$(tagsha aur-v0.1.6)" = "$(main)" ] && c=0 +check release_paths_read $c "rc=$rc tags=$(tags) $(tail -1 "$D/r.log")" +if grep -qF 'hack/release-paths' "$REL" && ! grep -qE -- '-- packaging scripts share Dockerfile' "$REL"; then + ok "release_cas.sh takes its path filter from hack/release-paths" +else + fail "release_cas.sh does not read hack/release-paths (or keeps a built-in path list)" +fi + # no_env_command_hook: the release script runs no command named by the environment. if grep -n 'BEFORE_PUSH' "$REL"; then fail "no_env_command_hook: env-named command in $REL" else ok "no_env_command_hook"; fi diff --git a/tests/bot_ci_dispatch_test.sh b/tests/bot_ci_dispatch_test.sh new file mode 100755 index 00000000..0c70bb74 --- /dev/null +++ b/tests/bot_ci_dispatch_test.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# hack/bot-ci-dispatch against a fake gh (no network). +# The fake keeps open PRs in prs.json and workflow runs in runs.json. `gh run +# list` filters runs like the API (-w workflow file, -c head SHA, -b branch, +# -e event, -L count, newest first). `gh workflow run` appends a queued +# workflow_dispatch run at the branch's head, unless the branch is listed in +# no_dispatch (its workflow file lacks the trigger: HTTP 422) or dispatch_fail +# exists (HTTP 500). `gh run rerun ID --failed` queues attempt 2 of that run. +# Every dispatch and rerun is logged to actions. +# +# Usage: tests/bot_ci_dispatch_test.sh + +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BCD=${BCD:-${ROOT}/hack/bot-ci-dispatch} +T=$(mktemp -d "${TMPDIR:-/tmp}/bot_ci_dispatch.XXXXXX") +trap 'rm -rf "$T"' EXIT +PASS=0 FAILS=0 +ok() { PASS=$((PASS + 1)); echo "ok $1"; } +fail() { FAILS=$((FAILS + 1)); echo "FAIL $1"; } + +mkdir -p "$T/bin" +cat > "$T/bin/gh" <<'STUB' +#!/usr/bin/env bash +F=$FIX +echo "gh $*" >> "$F/calls" +case "$1 $2" in + "pr list") cat "$F/prs.json"; exit 0;; + "run list") + shift 2; wf= c= b= e= q= L=1000 + while [ $# -gt 0 ]; do case $1 in + -w|--workflow) wf=$2; shift 2;; -c|--commit) c=$2; shift 2;; -b|--branch) b=$2; shift 2;; + -e|--event) e=$2; shift 2;; -q|--jq) q=$2; shift 2;; -L|--limit) L=$2; shift 2;; --json) shift 2;; *) shift;; esac; done + out=$(jq --arg w "$wf" --arg c "$c" --arg b "$b" --arg e "$e" --argjson L "$L" \ + '[.[] | select(($w == "" or .workflow == $w) and ($c == "" or .headSha == $c) + and ($b == "" or .headBranch == $b) and ($e == "" or .event == $e))] + | sort_by(.createdAt) | reverse | .[:$L]' "$F/runs.json") + if [ -n "$q" ]; then jq -r "$q" <<<"$out"; else echo "$out"; fi + exit 0;; + "workflow run") + wf=$3; shift 3; ref= + while [ $# -gt 0 ]; do case $1 in -r|--ref) ref=$2; shift 2;; *) shift;; esac; done + if grep -qxF "$ref" "$F/no_dispatch" 2>/dev/null; then + echo "could not create workflow dispatch event: HTTP 422: Workflow does not have 'workflow_dispatch' trigger (https://api.github.com/repos/o/r/actions/workflows/1/dispatches)" >&2 + exit 1 + fi + [ -f "$F/dispatch_fail" ] && { echo "could not create workflow dispatch event: HTTP 500: Server Error" >&2; exit 1; } + sha=$(jq -r --arg b "$ref" '[.[] | select(.headRefName == $b)][0].headRefOid // "unknown"' "$F/prs.json") + n=$(( $(jq length "$F/runs.json") + 900 )) + jq --arg w "$wf" --arg b "$ref" --arg s "$sha" --argjson id "$n" \ + '. + [{databaseId: $id, workflow: $w, event: "workflow_dispatch", headBranch: $b, headSha: $s, + status: "queued", conclusion: "", attempt: 1, createdAt: "2026-10-29T23:00:00Z"}]' \ + "$F/runs.json" > "$F/runs.tmp" && mv "$F/runs.tmp" "$F/runs.json" + echo "dispatch $wf $ref" >> "$F/actions" + exit 0;; + "run rerun") + id=$3; echo "rerun $id ${*:4}" >> "$F/actions" + jq --argjson id "$id" 'map(if .databaseId == $id then .attempt += 1 | .status = "queued" | .conclusion = "" else . end)' \ + "$F/runs.json" > "$F/runs.tmp" && mv "$F/runs.tmp" "$F/runs.json" + exit 0;; +esac +exit 0 +STUB +printf '#!/bin/sh\nexit 0\n' > "$T/bin/sleep" +chmod +x "$T/bin/"* + +BOT='{"login":"app/github-actions","is_bot":true}' +HUMAN='{"login":"88plug","is_bot":false}' +DEPENDABOT='{"login":"app/dependabot","is_bot":true}' +# pr NUMBER BRANCH SHA [AUTHOR] [CROSS]: one open PR into main. +pr() { + printf '{"number":%s,"headRefName":"%s","headRefOid":"%s","baseRefName":"main","isCrossRepository":%s,"author":%s}' \ + "$1" "$2" "$3" "${5:-false}" "${4:-$BOT}" +} +# run ID WORKFLOW EVENT BRANCH SHA STATUS CONCLUSION [ATTEMPT] [CREATED] +run() { + printf '{"databaseId":%s,"workflow":"%s","event":"%s","headBranch":"%s","headSha":"%s","status":"%s","conclusion":"%s","attempt":%s,"createdAt":"%s"}' \ + "$1" "$2" "$3" "$4" "$5" "$6" "$7" "${8:-1}" "${9:-2026-10-29T20:00:00Z}" +} +# fixture NAME PRS RUNS +fixture() { + FIX=$T/$1; export FIX; mkdir -p "$FIX" + echo "$2" > "$FIX/prs.json"; echo "$3" > "$FIX/runs.json"; : > "$FIX/calls"; : > "$FIX/actions" +} +bcd() { OUT=$(PATH="$T/bin:$PATH" timeout 60 bash "$BCD" "$@" 2>&1); RC=$?; } +acts() { grep -c "^$1 " "$FIX/actions"; } +dispatched() { grep -qxF "dispatch ${2:-ci.yml} $1" "$FIX/actions"; } + +# 1. scope: only same-repo autobump/* PRs by github-actions[bot] with no run at the +# head (a held run does not count) get a dispatch; forks, human branches, a +# human's autobump/ branch and dependabot never do. +fixture scope "[$(pr 1 autobump/a a111),$(pr 2 autobump/fork f222 "$BOT" true),$(pr 3 claude/x c333 "$HUMAN"),$(pr 4 autobump/human h444 "$HUMAN"),$(pr 5 dependabot/pip/x d555 "$DEPENDABOT"),$(pr 6 autobump/done e666),$(pr 7 autobump/held g777)]" \ + "[$(run 1 ci.yml pull_request autobump/done e666 completed success),$(run 2 ci.yml pull_request autobump/held g777 completed action_required)]" +BOT_CI_MAX_INFLIGHT=10 bcd +if [ "$RC" -eq 0 ] && [ "$(acts dispatch)" -eq 2 ] && dispatched autobump/a && dispatched autobump/held; then + ok "scope: dispatched autobump/a and the held autobump/held only" +else fail "scope: rc=$RC actions=$(tr '\n' ';' < "$FIX/actions") out=$(tr '\n' ';' <<<"$OUT")"; fi +if grep -q '#6 autobump/done' <<<"$OUT" && ! grep -qE '#(2|3|4|5) ' <<<"$OUT"; then + ok "scope: a line for the PR with a run at its head, none for the out-of-scope PRs" +else fail "scope: decision lines: $(tr '\n' ';' <<<"$OUT")"; fi + +# 2. the in-flight cap (default 1): two PRs, nothing running -> one dispatch, one deferred. +fixture cap "[$(pr 1 autobump/a a111),$(pr 2 autobump/b b222)]" '[]' +bcd +if [ "$RC" -eq 0 ] && [ "$(acts dispatch)" -eq 1 ] && dispatched autobump/a && grep -q '#2 autobump/b.*deferred' <<<"$OUT"; then + ok "cap: one dispatch, the second deferred" +else fail "cap: rc=$RC actions=$(tr '\n' ';' < "$FIX/actions") out=$(tr '\n' ';' <<<"$OUT")"; fi +# a bot run already in flight on another autobump branch fills the cap. +fixture cap_full "[$(pr 1 autobump/a a111)]" "[$(run 5 ci.yml workflow_dispatch autobump/z z999 in_progress '')]" +bcd +if [ "$RC" -eq 0 ] && [ "$(acts dispatch)" -eq 0 ] && grep -q 'deferred' <<<"$OUT"; then ok "cap_full: deferred" +else fail "cap_full: rc=$RC actions=$(tr '\n' ';' < "$FIX/actions")"; fi + +# 3. a queued ci.yml run of a human PR defers every bot dispatch; a running one does not. +fixture human_queued "[$(pr 1 autobump/a a111)]" "[$(run 5 ci.yml pull_request claude/x c333 queued '')]" +BOT_CI_MAX_INFLIGHT=10 bcd +if [ "$RC" -eq 0 ] && [ "$(acts dispatch)" -eq 0 ] && grep -q 'human' <<<"$OUT"; then ok "human_queued: deferred" +else fail "human_queued: rc=$RC actions=$(tr '\n' ';' < "$FIX/actions") out=$(tr '\n' ';' <<<"$OUT")"; fi +# a queued run whose head branch is gone (headBranch null) counts as a human run. +fixture human_null_branch "[$(pr 1 autobump/a a111)]" \ + "[{\"databaseId\":5,\"workflow\":\"ci.yml\",\"event\":\"pull_request\",\"headBranch\":null,\"headSha\":\"c333\",\"status\":\"queued\",\"conclusion\":\"\",\"attempt\":1,\"createdAt\":\"2026-10-29T20:00:00Z\"}]" +BOT_CI_MAX_INFLIGHT=10 bcd +if [ "$RC" -eq 0 ] && [ "$(acts dispatch)" -eq 0 ] && grep -q 'deferred, 1 ci.yml run(s) of human PRs queued' <<<"$OUT"; then + ok "human_null_branch: deferred" +else fail "human_null_branch: rc=$RC actions=$(tr '\n' ';' < "$FIX/actions") out=$(tr '\n' ';' <<<"$OUT")"; fi +fixture human_running "[$(pr 1 autobump/a a111)]" "[$(run 5 ci.yml pull_request claude/x c333 in_progress '')]" +bcd +if [ "$RC" -eq 0 ] && [ "$(acts dispatch)" -eq 1 ]; then ok "human_running: dispatched" +else fail "human_running: rc=$RC actions=$(tr '\n' ';' < "$FIX/actions")"; fi + +# 4. idempotent: a second call dispatches nothing more. +fixture idempotent "[$(pr 1 autobump/a a111),$(pr 2 autobump/b b222)]" '[]' +BOT_CI_MAX_INFLIGHT=10 bcd; first=$(acts dispatch); BOT_CI_MAX_INFLIGHT=10 bcd +if [ "$first" -eq 2 ] && [ "$(acts dispatch)" -eq 2 ] && [ "$RC" -eq 0 ]; then ok "idempotent: 2 dispatches, none on the second call" +else fail "idempotent: first=$first total=$(acts dispatch) rc=$RC"; fi + +# 5. a run that failed on its first attempt gets its failed jobs re-run once. +fixture rerun "[$(pr 1 autobump/a a111)]" "[$(run 41 ci.yml workflow_dispatch autobump/a a111 completed failure)]" +bcd +if [ "$RC" -eq 0 ] && grep -qxF 'rerun 41 --failed' "$FIX/actions" && [ "$(acts dispatch)" -eq 0 ]; then ok "rerun: failed jobs of run 41 re-run" +else fail "rerun: rc=$RC actions=$(tr '\n' ';' < "$FIX/actions") out=$(tr '\n' ';' <<<"$OUT")"; fi +jq 'map(if .databaseId == 41 then .status = "completed" | .conclusion = "failure" else . end)' "$FIX/runs.json" > "$FIX/r" && mv "$FIX/r" "$FIX/runs.json" +bcd +if [ "$RC" -eq 0 ] && [ "$(acts rerun)" -eq 1 ] && [ "$(acts dispatch)" -eq 0 ]; then ok "rerun: not again after the re-run failed" +else fail "rerun: second call rc=$RC actions=$(tr '\n' ';' < "$FIX/actions") out=$(tr '\n' ';' <<<"$OUT")"; fi +# a cancelled run is someone's decision: no re-run. +fixture rerun_cancelled "[$(pr 1 autobump/a a111)]" "[$(run 41 ci.yml workflow_dispatch autobump/a a111 completed cancelled)]" +bcd +if [ "$RC" -eq 0 ] && [ "$(acts rerun)" -eq 0 ] && [ "$(acts dispatch)" -eq 0 ]; then ok "rerun_cancelled: left alone" +else fail "rerun_cancelled: rc=$RC actions=$(tr '\n' ';' < "$FIX/actions")"; fi + +# 6. a branch whose ci.yml has no workflow_dispatch: skipped with a reason, exit 0. +fixture no_trigger "[$(pr 1 autobump/old o111),$(pr 2 autobump/b b222)]" '[]'; echo autobump/old > "$FIX/no_dispatch" +BOT_CI_MAX_INFLIGHT=10 bcd +if [ "$RC" -eq 0 ] && grep -q '#1 autobump/old.*workflow_dispatch' <<<"$OUT" && [ "$(acts dispatch)" -eq 1 ] && dispatched autobump/b; then + ok "no_trigger: skipped, the next PR still dispatched" +else fail "no_trigger: rc=$RC actions=$(tr '\n' ';' < "$FIX/actions") out=$(tr '\n' ';' <<<"$OUT")"; fi +# any other dispatch failure is an error: exit 1. +fixture dispatch_error "[$(pr 1 autobump/a a111)]" '[]'; touch "$FIX/dispatch_fail" +bcd +if [ "$RC" -eq 1 ] && grep -q 'HTTP 500' <<<"$OUT"; then ok "dispatch_error: exit 1" +else fail "dispatch_error: rc=$RC out=$(tr '\n' ';' <<<"$OUT")"; fi + +# 7. --branch limits it to one PR; --workflow picks the workflow and its own runs. +fixture one_branch "[$(pr 1 autobump/a a111),$(pr 2 autobump/b b222)]" '[]' +BOT_CI_MAX_INFLIGHT=10 bcd --branch autobump/b +if [ "$RC" -eq 0 ] && [ "$(acts dispatch)" -eq 1 ] && dispatched autobump/b; then ok "one_branch: only autobump/b" +else fail "one_branch: rc=$RC actions=$(tr '\n' ';' < "$FIX/actions")"; fi +fixture canary "[$(pr 1 autobump/a a111)]" "[$(run 5 ci.yml workflow_dispatch autobump/a a111 completed success)]" +bcd --workflow canary.yml +if [ "$RC" -eq 0 ] && [ "$(acts dispatch)" -eq 1 ] && dispatched autobump/a canary.yml; then ok "workflow: canary.yml dispatched beside a green ci.yml run" +else fail "workflow: rc=$RC actions=$(tr '\n' ';' < "$FIX/actions")"; fi +bcd --bogus +if [ "$RC" -eq 2 ]; then ok "usage: unknown flag exits 2"; else fail "usage: rc=$RC"; fi + +echo "${PASS} passed, ${FAILS} failed" +[ "${FAILS}" -eq 0 ] diff --git a/tests/canary_node_test.sh b/tests/canary_node_test.sh new file mode 100755 index 00000000..0517da65 --- /dev/null +++ b/tests/canary_node_test.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# canary/node.sh against a docker, k3d and curl shim: the cluster's pods come from +# scenario files, one per `kubectl get pods` poll (the last repeats), so each case reads +# the verdict node.sh writes for a cluster whose pods behave a known way. No image is +# built, no cluster is started, nothing is downloaded. +# +# Usage: tests/canary_node_test.sh + +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +T=$(mktemp -d "${TMPDIR:-/tmp}/canary_node_test.XXXXXX") +trap 'rm -rf "$T"' EXIT +PASS=0 FAILS=0 +ok() { PASS=$((PASS + 1)); echo "ok $1"; } +fail() { FAILS=$((FAILS + 1)); echo "FAIL $1"; } + +# A tree node.sh reads: the canary, the Dockerfile and the manifests it bakes, and an +# install.sh whose k3d sha256 is the shim's. +R=${T}/root +mkdir -p "${R}/packaging" "${T}/bin" "${T}/state" +cp -r "${ROOT}/canary" "${ROOT}/share" "${ROOT}/Dockerfile" "${R}/" +printf '#!/bin/sh\necho "k3d $*" >> "${STUB_STATE}/calls"\ncase "$1 $2" in "cluster create") exit "${STUB_CREATE_RC:-0}" ;; esac\nexit 0\n' \ + > "${T}/k3d.src" +printf 'K3D_VERSION=v5.9.0\nK3D_SHA256_AMD64=%s\n' "$(sha256sum "${T}/k3d.src" | cut -d' ' -f1)" > "${R}/packaging/install.sh" + +# What the node holds when everything applied: the baked manifests and their +# DaemonSets, read here with a YAML parser (node.sh reads them its own way). +grep -oE '/var/lib/rancher/k3s/server/manifests/[^ ]+$' "${ROOT}/Dockerfile" | xargs -n1 basename > "${T}/manifests" +sed -nE 's#^COPY (share/[^ ]+) /var/lib/rancher/k3s/server/manifests/.*#\1#p' "${ROOT}/Dockerfile" \ + | (cd "${ROOT}" && xargs python3 -c ' +import sys, yaml +for p in sys.argv[1:]: + for d in yaml.safe_load_all(open(p)): + if d and d.get("kind") == "DaemonSet": + print("%s/%s" % (d["metadata"].get("namespace", "default"), d["metadata"]["name"])) +') | sort -u > "${T}/daemonsets" +[ -s "${T}/daemonsets" ] && grep -qx 'node-feature-discovery/nfd-worker' "${T}/daemonsets" \ + && ok "fixture: the baked manifests define $(wc -l < "${T}/daemonsets") DaemonSets, NFD's among them" \ + || fail "fixture: no DaemonSets read from the baked manifests" + +cat > "${T}/bin/curl" <<'SHIM' +#!/usr/bin/env bash +out="" +while [ $# -gt 0 ]; do case "$1" in -o) out=$2; shift 2 ;; *) shift ;; esac; done +cp "${STUB_K3D_SRC:?}" "${out:?}" +SHIM +cat > "${T}/bin/docker" <<'SHIM' +#!/usr/bin/env bash +S=${STUB_STATE:?} +echo "docker $*" >> "${S}/calls" +case "$1" in + buildx) exit "${STUB_BUILD_RC:-0}" ;; + exec) shift 2 ;; + *) exit 0 ;; +esac +case "$*" in + "ls /var/lib/rancher/k3s/server/manifests") cat "${STUB_MANIFESTS:?}" ;; + "kubectl get nodes -o jsonpath="*) echo True ;; + "kubectl get ds -A -o jsonpath="*) grep -vxF "${STUB_DS_DROP:-none}" "${STUB_DAEMONSETS:?}" ;; + "kubectl get pods -A -o jsonpath="*) + n=$(( $(cat "${S}/polls" 2>/dev/null || echo 0) + 1 )) + echo "${n}" > "${S}/polls" + f=$(ls "${STUB_PODS:?}" | sort -n | awk -v n="${n}" '$1 <= n' | tail -1) + cat "${STUB_PODS}/${f}" ;; + "kubectl get pods"*) echo "kubectl get pods: node.sh asks for all namespaces with -A" >&2; exit 1 ;; +esac +exit 0 +SHIM +chmod +x "${T}/bin/curl" "${T}/bin/docker" +export PATH="${T}/bin:${PATH}" STUB_STATE="${T}/state" STUB_K3D_SRC="${T}/k3d.src" +export STUB_MANIFESTS="${T}/manifests" STUB_DAEMONSETS="${T}/daemonsets" +export CANARY_NODE_TIMEOUT=4 CANARY_NODE_SETTLE=2 CANARY_NODE_POLL=0.2 + +UP=$'kube-system/coredns-1 Running coredns=true:0,\nkube-system/helm-install-x Succeeded helm=false:0,\nkube-system/intel-gpu-plugin-a Running intel-gpu-plugin=true:0,\nnode-feature-discovery/nfd-master-1 Running nfd-master=true:0,\nnode-feature-discovery/nfd-worker-a Running nfd-worker=true:0,' + +# node_case <label> <pod states per poll...> [-- VAR=value...]: node.sh in a fresh state; +# leaves its node.json in RESULT and its exit status in RC. +node_case() { + local label=$1 i=0 + shift + rm -rf "${T}/state" "${T}/out" "${T}/pods" + mkdir -p "${T}/state" "${T}/pods" + while [ $# -gt 0 ] && [ "$1" != -- ]; do + i=$((i + 1)) + printf '%s\n' "$1" > "${T}/pods/${i}" + shift + done + [ "${1:-}" != -- ] || shift + START=${SECONDS} + env "$@" STUB_PODS="${T}/pods" CANARY_OUT_DIR="${T}/out" "${R}/canary/node.sh" > "${T}/${label}.log" 2>&1 + RC=$? + ELAPSED=$((SECONDS - START)) + RESULT=${T}/out/node.json + [ -f "${RESULT}" ] || { fail "${label}: no node.json (rc=${RC}); log:"; sed 's/^/ /' "${T}/${label}.log" | tail -20; return 1; } +} +field() { python3 -c 'import json, sys; v = json.load(open(sys.argv[1]))[sys.argv[2]]; print(json.dumps(v) if not isinstance(v, str) else v)' "${RESULT}" "$1"; } +expect() { # expect <label> <field> <value> + local got + got=$(field "$2") + if [ "${got}" = "$3" ]; then ok "$1: $2=$3"; else fail "$1: $2=${got}, want $3 ($(field detail))"; fi +} +detail_has() { # detail_has <label> <text> + grep -qF -- "$2" <(field detail) && ok "$1: detail names $2" || fail "$1: detail is '$(field detail)'" +} + +# 1. Everything up and steady: pass, hard and exact. +node_case up "${UP}" +expect up verdict pass +expect up hard true +expect up kind node +[ "${RC}" -eq 0 ] && ok "up: node.sh exits 0" || fail "up: node.sh exited ${RC}" +grep -q 'k3d cluster create canary .*--no-lb' "${T}/state/calls" && ok "up: the cluster is created with NODE_ARGS" \ + || fail "up: no k3d cluster create with --no-lb" + +# 2. A pod outside kube-system that crash-loops (NFD after a bad bump) fails. +node_case nfd "${UP/nfd-master=true:0/nfd-master=false:4}" +expect nfd verdict fail +detail_has nfd node-feature-discovery/nfd-master-1 + +# 3. A baked DaemonSet's pod that never starts (its image does not pull) fails. +node_case pull "${UP/intel-gpu-plugin-a Running intel-gpu-plugin=true:0/intel-gpu-plugin-a Pending intel-gpu-plugin=false:0}" +expect pull verdict fail +detail_has pull kube-system/intel-gpu-plugin-a +[ "${ELAPSED}" -le 15 ] && ok "pull: gave up at the timeout (${ELAPSED}s)" || fail "pull: took ${ELAPSED}s" + +# 4. A pod that comes up late passes once it has held. +node_case late "${UP/intel-gpu-plugin-a Running intel-gpu-plugin=true:0/intel-gpu-plugin-a Pending intel-gpu-plugin=false:0}" "${UP}" +expect late verdict pass + +# 5. A container that is ready for a moment and then crashes, again and again, fails: +# it never holds without a restart. +flap=() +for r in $(seq 60); do + flap+=("${UP/nfd-master=true:0/nfd-master=true:${r}}") +done +node_case flap "${flap[@]}" +expect flap verdict fail +detail_has flap "did not hold" + +# 6. One restart while the cluster settles, then steady: pass. +node_case restart-once "${UP}" "${UP/coredns=true:0/coredns=true:1}" +expect restart-once verdict pass + +# 7. A DaemonSet a baked manifest defines that the cluster lacks fails. +node_case nods "${UP}" -- STUB_DS_DROP=node-feature-discovery/nfd-worker +expect nods verdict fail +detail_has nods "DaemonSet node-feature-discovery/nfd-worker missing" + +# 8. No pods at all fails. +node_case nopods "" +expect nopods verdict fail + +# 9. A failed cluster create fails, naming it. +node_case create "${UP}" -- STUB_CREATE_RC=1 +expect create verdict fail +detail_has create "k3d cluster create failed" + +echo "${PASS} passed, ${FAILS} failed" +[ "${FAILS}" -eq 0 ] diff --git a/tests/canary_spec_test.sh b/tests/canary_spec_test.sh new file mode 100755 index 00000000..4b3e43fc --- /dev/null +++ b/tests/canary_spec_test.sh @@ -0,0 +1,354 @@ +#!/usr/bin/env bash +# canary/engines.toml against the real tree, canary/flags.sh, canary/plan.py's planning +# (auto against fixture diffs, explicit lists, the schedule) and report, and the shape +# of .github/workflows/canary.yml. Offline: no image, no registry, no network. +# +# Usage: tests/canary_spec_test.sh + +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PLAN=${ROOT}/canary/plan.py +T=$(mktemp -d "${TMPDIR:-/tmp}/canary_spec.XXXXXX") +trap 'rm -rf "$T"' EXIT +PASS=0 FAILS=0 +ok() { PASS=$((PASS + 1)); echo "ok $1"; } +fail() { FAILS=$((FAILS + 1)); echo "FAIL $1"; } +jget() { python3 -c 'import json, sys; d = json.load(sys.stdin) +for k in sys.argv[1:]: d = d[k] +print(d if isinstance(d, str) else json.dumps(d))' "$@"; } + +WANT_IDS="dlstreamer eat-vllm-cpu gaudi-vllm llama-cpu llama-full llama-sycl llama-vulkan llm-scaler node npu-umd ovms ovms-cpu sglang-xeon vllm-cpu vllm-xpu" +ids=$(python3 "${PLAN}" ids | sort | tr '\n' ' ') +[ "${ids% }" = "${WANT_IDS}" ] && ok "engines.toml has every engine of the spec" || fail "engine ids are '${ids}'" + +# --- the real tree -------------------------------------------------------------------- +FLAGS=$("${ROOT}/canary/flags.sh") && ok "flags.sh runs on the tree" || fail "flags.sh failed" +for id in ${WANT_IDS}; do + if ! ref=$(python3 "${PLAN}" ref "${id}" 2> "${T}/err"); then + fail "${id}: locator does not match exactly one ref: $(cat "${T}/err")" + continue + fi + [[ "${ref}" =~ ^[a-z0-9./_-]+(:[A-Za-z0-9._-]+)?@sha256:[0-9a-f]{64}$ ]] && ok "${id}: ${ref%%@*}@sha256:..." \ + || fail "${id}: '${ref}' is not name[:tag]@sha256:<64 hex>" + show=$(python3 "${PLAN}" show "${id}" 2> "${T}/err") || { fail "${id}: plan.py show: $(cat "${T}/err")"; continue; } + kind=$(jget kind <<< "${show}") + if [ "${kind}" = runtime ]; then + rev=$(jget revision <<< "${show}") + [[ "${rev}" =~ ^[0-9a-f]{40}$ ]] && ok "${id}: revision is 40-hex" || fail "${id}: revision '${rev}'" + g=$(jget golden <<< "${show}") + if python3 - "${ROOT}/${g}" <<'PY' +import json, re, sys +d = json.load(open(sys.argv[1])) +assert sorted(d) == ["exact", "prompt", "recorded_with", "regex"], sorted(d) +assert d["prompt"] == "The capital of France is", d["prompt"] +re.compile(d["regex"]) +assert d["exact"] is None or isinstance(d["exact"], str) +assert d["exact"] is None or re.search(d["regex"], d["exact"]), "the golden text fails its own regex" +PY + then ok "${id}: golden ${g} is well formed"; else fail "${id}: golden ${g}"; fi + fi + if [ "${kind}" != node ]; then + n=$(jget flags <<< "${show}" | python3 -c 'import json, sys; print(len(json.load(sys.stdin)))') + [ "${n}" -gt 0 ] && ok "${id}: flags.sh names ${n} flags/envs for it" || fail "${id}: no flags from flags.sh" + fam=$(jget family <<< "${show}") + for s in $(jget flags_source <<< "${show}" | python3 -c 'import json, sys; print(" ".join(json.load(sys.stdin)))'); do + grep -qP "^${fam//./\\.}\t${s//./\\.}\t" <<< "${FLAGS}" \ + || fail "${id}: flags.sh prints no ${fam} names from flags_source ${s}" + done + fi +done + +# tree <dir>: the files planning reads, copied from the real tree. +tree() { + mkdir -p "$1" + (cd "${ROOT}" && cp -r --parents Dockerfile scripts/k3d-gpu console/plan.py console/model_opt.py console/fit.py \ + console/moe_predict.py bench/h54/h54.py integrations/eat/vllm-values.yaml packaging/install.sh share canary "$1") +} + +# Exactly one match: a duplicated pin line is refused. +mkdir -p "${T}/dup/scripts" "${T}/dup/canary" +cp "${ROOT}/canary/engines.toml" "${T}/dup/canary/" +grep -m1 '^LLAMA_CPU_IMAGE=' "${ROOT}/scripts/k3d-gpu" > "${T}/dup/scripts/k3d-gpu" +grep -m1 '^LLAMA_CPU_IMAGE=' "${ROOT}/scripts/k3d-gpu" >> "${T}/dup/scripts/k3d-gpu" +if python3 "${PLAN}" ref llama-cpu --root "${T}/dup" > /dev/null 2>&1; then fail "a locator matching twice resolved" +else ok "a locator matching twice is an error"; fi +# Both reference forms, whichever the tree holds today: the OVMS pin rewritten to +# name@sha256 and to name:tag@sha256. The pins themselves move with every bump, so no +# test here names their current values. +tree "${T}/form" +sed -i -E 's#^(AGENT_OVMS_IMAGE="[^@":]+)(:[^@"]+)?@#\1@#' "${T}/form/scripts/k3d-gpu" +ref=$(python3 "${PLAN}" ref ovms-cpu --root "${T}/form") +tag=$(python3 "${PLAN}" show ovms-cpu --root "${T}/form" | jget tag) +[[ "${ref}" == openvino/model_server@sha256:* && -z "${tag}" ]] && ok "a locator reads the name@sha256 form, with no tag" \ + || fail "name@sha256 form resolved to '${ref}', tag '${tag}'" +sed -i -E 's#^(AGENT_OVMS_IMAGE="[^@":]+)@#\1:9.9.9-gpu@#' "${T}/form/scripts/k3d-gpu" +ref=$(python3 "${PLAN}" ref ovms-cpu --root "${T}/form") +[[ "${ref}" == openvino/model_server:9.9.9-gpu@sha256:* ]] && ok "a locator reads the name:tag@sha256 form" \ + || fail "name:tag form resolved to '${ref}'" +tag=$(python3 "${PLAN}" show ovms-cpu --root "${T}/form" | jget tag) +[ "${tag}" = 9.9.9-gpu ] && ok "the tag is split from a name:tag@sha256 ref" || fail "tag is '${tag}'" + +# Every derived constant resolves to exactly one value. +for id in vllm-xpu llm-scaler gaudi-vllm npu-umd; do + vals=$(python3 "${PLAN}" show "${id}" | jget derived | python3 -c 'import json, sys; print(" ".join(d["value"] for d in json.load(sys.stdin)))') + [ -n "${vals}" ] && ok "${id}: derived constants ${vals}" || fail "${id}: no derived constants" +done +v=$(python3 "${PLAN}" show vllm-xpu | jget derived | python3 -c 'import json, sys; print(" ".join(sorted({d["value"] for d in json.load(sys.stdin)})))') +[[ "${v}" =~ ^[0-9.]+$ ]] && ok "vllm-xpu: plan.py, model_opt.py and the launcher agree (${v})" || fail "vllm-xpu derived '${v}'" + +# --- flags.sh ------------------------------------------------------------------------- +has() { grep -qP "^$1\t[^\t]+\t$2\t$3$" <<< "${FLAGS}"; } +for want in "vllm flag --served-model-name" "vllm flag --no-enable-prefix-caching" "vllm env VLLM_WORKER_MULTIPROC_METHOD" \ + "vllm env VLLM_CPU_KVCACHE_SPACE" "vllm flag --speculative-config" "ovms flag --target_device" \ + "ovms flag --draft_model_path" "llama\\.cpp flag --load-mode" "llama\\.cpp flag -ngl" "llama\\.cpp flag -md" \ + "llama-convert flag --outtype" "llama-convert flag --outfile" "dlstreamer element gvadetect" \ + "dlstreamer property gvadetect\\.pre-process-backend" "sglang flag --attention-backend"; do + read -r f k n <<< "${want}" + has "${f}" "${k}" "${n}" && ok "flags.sh: ${f//\\/} ${k} ${n//\\/}" || fail "flags.sh lacks ${want}" +done +grep -qP '^sglang\t[^\t]+\tflag\t-m$' <<< "${FLAGS}" && fail "flags.sh counts python's -m as an sglang flag" \ + || ok "flags.sh: sglang flags start after the module name" +# A flag literal with no engine test fails flags.sh, so a new emitter cannot hide. +mkdir -p "${T}/emit/scripts" "${T}/emit/console" "${T}/emit/bench/h54" +cp "${ROOT}/scripts/k3d-gpu" "${T}/emit/scripts/" +cp "${ROOT}/console/fit.py" "${T}/emit/console/" +cp "${ROOT}/bench/h54/h54.py" "${T}/emit/bench/h54/" +{ cat "${ROOT}/console/model_opt.py"; printf '\n\ndef new_emitter():\n return ["--brand-new-flag"]\n'; } > "${T}/emit/console/model_opt.py" +if "${ROOT}/canary/flags.sh" --root "${T}/emit" > /dev/null 2> "${T}/err"; then fail "flags.sh accepted an unattributed flag literal" +elif grep -q 'new_emitter.*--brand-new-flag' "${T}/err"; then ok "flags.sh names an unattributed flag literal and fails" +else fail "flags.sh failed without naming the literal: $(cat "${T}/err")"; fi + +# --- planning ------------------------------------------------------------------------- +tree "${T}/base" +# plan_auto <label> <expected ids> <edit command run in the head tree...> +plan_auto() { + local label=$1 want=$2 got + shift 2 + rm -rf "${T}/head" + cp -r "${T}/base" "${T}/head" + (cd "${T}/head" && "$@") || { fail "${label}: edit failed"; return; } + # An edit whose pattern no longer matches the tree would read as "selected nothing". + if [ "$1" != true ] && diff -rq "${T}/base" "${T}/head" > /dev/null; then + fail "${label}: the edit changed nothing in the tree" + return + fi + got=$(python3 "${PLAN}" matrix --mode auto --base-root "${T}/base" --root "${T}/head" | jget selected \ + | python3 -c 'import json, sys; print(" ".join(sorted(e["id"] for e in json.load(sys.stdin))))') + [ "${got}" = "${want}" ] && ok "auto, ${label}: ${want:-nothing}" || fail "auto, ${label}: selected '${got}', want '${want}'" +} +digest() { printf '%064d' "$1"; } +# edit <file> <old> <new>: replace the one occurrence of <old>. +edit() { + python3 - "$@" <<'PY' +import sys +p, old, new = sys.argv[1:] +s = open(p).read() +assert s.count(old) == 1, "%s: %r occurs %d times" % (p, old, s.count(old)) +open(p, "w").write(s.replace(old, new)) +PY +} +plan_auto "no change" "" true +plan_auto "SYCL digest" "llama-sycl" \ + sed -i -E "s/(LLAMA_SYCL_IMAGE=.*@sha256:)[0-9a-f]{64}/\1$(digest 1)/" scripts/k3d-gpu +plan_auto "OVMS digest (two engines locate it)" "ovms ovms-cpu" \ + sed -i -E "s/(AGENT_OVMS_IMAGE=.*@sha256:)[0-9a-f]{64}/\1$(digest 2)/" scripts/k3d-gpu +plan_auto "NPU user space pin" "npu-umd" \ + sed -i -E "s/(NPU_UMD_IMAGE=.*@sha256:)[0-9a-f]{64}/\1$(digest 3)/" scripts/k3d-gpu +plan_auto "EAT's vLLM tag" "eat-vllm-cpu" \ + sed -i -E 's/^( tag: ")[^"@]+@/\1v9.9.9@/' integrations/eat/vllm-values.yaml +plan_auto "sglang in moe_predict" "sglang-xeon" \ + sed -i -E "s/(lmsysorg\\/sglang(:[^@\"]+)?@sha256:)[0-9a-f]{64}/\1$(digest 4)/" console/moe_predict.py +plan_auto "k3s pin" "node" sed -i 's/^ARG K3S_TAG="v[^"]*"/ARG K3S_TAG="v9.9.9-k3s1"/' Dockerfile +plan_auto "a baked manifest" "node" sed -i '1i # touched' share/intel-gpu-plugin.yml +plan_auto "a derived constant" "vllm-xpu" sed -i -E 's/"GPU": "[0-9.]+"/"GPU": "9.9.9"/' console/plan.py +plan_auto "a vllm_manifest flag" "gaudi-vllm llm-scaler vllm-cpu vllm-xpu" \ + edit scripts/k3d-gpu '"${item}--port=8000"' '"${item}--port=8000"'$'\n'' args+="${item}--enable-chunked-prefill"' +plan_auto "an OVMS manifest flag" "npu-umd ovms ovms-cpu" \ + edit scripts/k3d-gpu $' - --rest_port=8000\n - --model_path=/models/${AGENT_MODEL}\n' \ + $' - --rest_port=8000\n - --grpc_port=9000\n - --model_path=/models/${AGENT_MODEL}\n' +plan_auto "an unrelated launcher edit" "" edit scripts/k3d-gpu 'K3D_GPU_AGENT_STORAGE:-20Gi' 'K3D_GPU_AGENT_STORAGE:-30Gi' +plan_auto "a golden" "llama-cpu" sed -i 's/"recorded_with": {/"recorded_with": {"note": "x", /' canary/goldens/llama-cpu.json +plan_auto "one engine's table" "llama-cpu" sed -i 's/"-c", "2048", "-np"/"-c", "4096", "-np"/' canary/engines.toml +all_ids=$(echo "${WANT_IDS}") +plan_auto "the harness itself" "${all_ids}" sed -i '1a # touched' canary/run.sh +plan_auto "the defaults table" "${all_ids}" sed -i 's/^ready_timeout = 600/ready_timeout = 900/' canary/engines.toml +plan_auto "the canary's README alone" "" sed -i '1a touched' canary/README.md + +# The same through git: --base names a commit, and the tree is read from it. +mkdir -p "${T}/repo" +cp -r "${T}/base/." "${T}/repo/" +( + set -e + cd "${T}/repo" + export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 + export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t + git init -q -b main . + git add -A + git commit -qm base + sed -i -E "s/(LLAMA_VULKAN_IMAGE=.*@sha256:)[0-9a-f]{64}/\1$(digest 5)/" scripts/k3d-gpu + git commit -qam bump +) || fail "git fixture" +got=$(python3 "${PLAN}" matrix --mode auto --base HEAD~1 --root "${T}/repo" | jget selected \ + | python3 -c 'import json, sys; print(" ".join(e["id"] for e in json.load(sys.stdin)))') +[ "${got}" = llama-vulkan ] && ok "auto against a git base commit: llama-vulkan" || fail "auto --base HEAD~1 selected '${got}'" + +got=$(python3 "${PLAN}" matrix --engines llama-cpu,node | jget selected | python3 -c 'import json, sys; print(" ".join(e["id"] for e in json.load(sys.stdin)))') +[ "${got}" = "llama-cpu node" ] && ok "an explicit list selects exactly it" || fail "--engines llama-cpu,node selected '${got}'" +python3 "${PLAN}" matrix --engines llama-cpu,nope > /dev/null 2>&1 && fail "an unknown engine id was accepted" \ + || ok "an unknown engine id is refused" +all=$(python3 "${PLAN}" matrix --mode all) +n=$(jget selected <<< "${all}" | python3 -c 'import json, sys; print(len(json.load(sys.stdin)))') +[ "${n}" -eq 15 ] && ok "the schedule selects all 15 engines" || fail "--mode all selected ${n}" +python3 - <<'PY' "${all}" "$(python3 "${PLAN}" show llama-cpu)" && ok "matrix: runtime rows carry the model cache key, surface rows the id, node a flag" || fail "matrix shape" +import json, sys +m = json.loads(sys.argv[1]) +eng = json.loads(sys.argv[2]) +rt = {r["id"]: r for r in m["runtime"]} +assert sorted(rt) == ["eat-vllm-cpu", "llama-cpu", "ovms-cpu", "vllm-cpu"], sorted(rt) +r = rt["llama-cpu"] +# The key holds the model and its revision, so a revision bump misses the old cache. +slug = "%s@%s" % (eng["model"].replace("/", "__"), eng["revision"]) +assert r["cache_key"] == "canary-model-" + slug, (r, slug) +assert r["cache_path"] == "~/.cache/k3d-gpu-canary/" + slug, (r, slug) +assert len(m["surface"]) == 10 and all(set(s) == {"id"} for s in m["surface"]), m["surface"] +assert m["node"] is True +assert {e["id"]: e["informational"] for e in m["selected"]}["eat-vllm-cpu"] is True +PY +python3 "${PLAN}" matrix --mode all --github-output "${T}/gh" > /dev/null +grep -qx 'node=true' "${T}/gh" && grep -q '^runtime=\[' "${T}/gh" && grep -q '^surface=\[' "${T}/gh" && grep -q '^selected=\[' "${T}/gh" \ + && ok "matrix --github-output writes runtime, surface, node and selected" || fail "github output: $(cat "${T}/gh")" + +# --- report --------------------------------------------------------------------------- +part() { # part <id> <kind> <verdict> <hard> <exact> + printf '{"kind": "%s", "pinned_ref": "x/%s@sha256:%s", "verdict": "%s", "hard": %s, "exact": %s, "cpu_flags": ["avx2"], "detail": "d"}' \ + "$2" "$1" "$(digest 9)" "$3" "$4" "$5" > "${T}/parts/$1.json" +} +report() { # report <selected ids...>: runs plan.py report, sets RRC and leaves canary-result.json + local sel + sel=$(python3 -c 'import json, sys; print(json.dumps([{"id": i, "kind": "runtime", "informational": i.startswith("eat-")} for i in sys.argv[1:]]))' "$@") + python3 "${PLAN}" report --selected "${sel}" --parts "${T}/parts" --sha abc --ref claude/x --out "${T}/result.json" \ + --summary "${T}/summary.md" --github-output "${T}/rgh" > "${T}/report.log" 2>&1 + RRC=$? +} +rm -rf "${T}/parts"; mkdir -p "${T}/parts" +part llama-cpu runtime pass true true +part vllm-xpu surface pass true true +report llama-cpu vllm-xpu +[ "${RRC}" -eq 0 ] && ok "report: every engine green exits 0" || fail "report exited ${RRC}: $(cat "${T}/report.log")" +python3 - "${T}/result.json" <<'PY' && ok "report: canary-result.json has the interface schema" || fail "canary-result.json schema" +import json, sys +d = json.load(open(sys.argv[1])) +assert sorted(d) == ["engines", "ref", "sha"] and d["sha"] == "abc" and d["ref"] == "claude/x", d +for k, e in d["engines"].items(): + assert sorted(e) == ["cpu_flags", "detail", "exact", "hard", "kind", "pinned_ref", "verdict"], e + assert e["kind"] in ("runtime", "surface", "node") + assert e["verdict"] in ("pass", "fail", "isa-skip", "too-big", "unparseable") + assert isinstance(e["hard"], bool) and isinstance(e["exact"], bool) and isinstance(e["cpu_flags"], list) +PY +grep -q 'llama-cpu' "${T}/summary.md" && ok "report: the step summary lists the engines" || fail "no summary" +part llama-cpu runtime pass true false +report llama-cpu +[ "${RRC}" -eq 0 ] && ok "report: exact false alone is still green" || fail "report exited ${RRC} on an exact mismatch" +part llama-cpu runtime fail false false +report llama-cpu vllm-xpu +[ "${RRC}" -ne 0 ] && ok "report: one failed engine exits non-zero" || fail "report exited 0 with a failed engine" +part llama-cpu runtime pass true true +part eat-vllm-cpu runtime fail false false +report llama-cpu eat-vllm-cpu +[ "${RRC}" -eq 0 ] && ok "report: an informational failure does not fail it" || fail "report exited ${RRC} on an informational failure" +report llama-cpu ovms-cpu +[ "${RRC}" -ne 0 ] && ok "report: a selected engine with no result fails" || fail "report exited 0 with a missing result" +[ "$(jget engines ovms-cpu verdict < "${T}/result.json")" = fail ] && ok "report: the missing engine is recorded as fail" \ + || fail "missing engine verdict $(jget engines ovms-cpu verdict < "${T}/result.json")" +part vllm-cpu runtime isa-skip false false +report vllm-cpu llama-cpu +[ "${RRC}" -ne 0 ] && ok "report: isa-skip is not green" || fail "report exited 0 on isa-skip" +grep -qx 'isa_skip=vllm-cpu' "${T}/rgh" && ok "report: isa-skip engines go to the re-dispatch output" || fail "rgh: $(cat "${T}/rgh")" +part vllm-cpu runtime pass true true +report vllm-cpu +grep -qx 'isa_skip=' "${T}/rgh" && ok "report: no isa-skip, no re-dispatch" || fail "rgh: $(cat "${T}/rgh")" +report +[ "${RRC}" -eq 0 ] && [ "$(jget engines < "${T}/result.json")" = "{}" ] && ok "report: nothing selected is green and empty" \ + || fail "empty report rc=${RRC}" + +# --- the workflow ----------------------------------------------------------------------- +python3 - "${ROOT}/.github/workflows/canary.yml" <<'PY' && ok "canary.yml: triggers, permissions, timeouts, pins, artifact" || fail "canary.yml shape" +import re, sys, yaml +src = open(sys.argv[1]).read() +w = yaml.safe_load(src) +on = w.get(True) or w.get("on") +assert w["name"] == "Canary", w["name"] +assert [c["cron"] for c in on["schedule"]] == ["29 3 * * *"], on["schedule"] +inp = on["workflow_dispatch"]["inputs"] +assert str(inp["engines"]["default"]) == "auto" and str(inp["attempt"]["default"]) == "1", inp +paths = set(on["pull_request"]["paths"]) +need = {"Dockerfile", "scripts/k3d-gpu", "share/**", "console/moe_predict.py", "console/plan.py", + "console/model_opt.py", "canary/**", "packaging/install.sh"} +assert need <= paths, need - paths +assert w["permissions"] == {"contents": "read"}, w["permissions"] +assert w["concurrency"]["group"] == "canary-${{ github.ref }}", w["concurrency"] +assert "pull_request" in str(w["concurrency"]["cancel-in-progress"]), w["concurrency"] +jobs = w["jobs"] +for name, j in jobs.items(): + assert "timeout-minutes" in j, name + " has no timeout-minutes" + assert "permissions" in j, name + " has no permissions" + assert j["runs-on"] == "ubuntu-latest", name + " must run on a GitHub-hosted runner" +assert jobs["runtime"]["timeout-minutes"] == 45 and jobs["runtime"]["strategy"]["fail-fast"] is False +assert jobs["surface"]["timeout-minutes"] == 30 and jobs["node"]["timeout-minutes"] == 40 +assert "always()" in jobs["report"]["if"] +writes = {n for n, j in jobs.items() if any(v == "write" for v in (j["permissions"] or {}).values())} +assert writes <= {"redispatch"}, writes +assert "secrets." not in src, "no secrets" +for u in re.findall(r"uses:\s*(\S+)", src): + assert re.fullmatch(r"[\w.-]+/[\w./-]+@[0-9a-f]{40}", u), "not pinned by SHA: " + u +assert "name: canary-result" in src +assert not re.search(r"\b(v?\d+\.\d+\.\d+)\b", re.sub(r"#.*", "", src)), "a version literal in the workflow" +PY + +# A pull request runs the canary when it changes any file the plan reads (canary/plan.py's +# BASE_PATHS, each engine's locator, derived and flags_source files, the node's paths), or +# a change that selects an engine would merge without its canary. +python3 - "${ROOT}" <<'PY' && ok "canary.yml: pull_request paths cover every file the plan reads" || fail "canary.yml: pull_request paths miss a file the plan reads" +import fnmatch, importlib.util, os, sys, yaml +root = sys.argv[1] +spec = importlib.util.spec_from_file_location("plan", os.path.join(root, "canary/plan.py")) +plan = importlib.util.module_from_spec(spec) +spec.loader.exec_module(plan) +_, engines = plan.load(root) +need = set(plan.BASE_PATHS) +for e in engines.values(): + need.add(e["locator"]["file"]) + need.update(d["file"] for d in e.get("derived", [])) + need.update(p.rstrip("/") for p in e.get("paths", [])) + need.update(s.split(":", 1)[0] for s in e.get("flags_source", [])) +w = yaml.safe_load(open(os.path.join(root, plan.WORKFLOW))) +pats = (w.get(True) or w.get("on"))["pull_request"]["paths"] +# GitHub's ** matches across directories; a directory is covered when a file in it is. +missing = sorted(p for p in need + if not any(fnmatch.fnmatch(p + "/f" if os.path.isdir(os.path.join(root, p)) else p, pat) for pat in pats)) +assert not missing, "not in on.pull_request.paths: %s" % missing +PY + +# node.sh creates the cluster with the arguments the launcher's create passes on a host +# without NVIDIA; each must still be one the launcher passes, word for word. +node_args=$(sed -n 's/^NODE_ARGS=(\(.*\))$/\1/p' "${ROOT}/canary/node.sh") +[ -n "${node_args}" ] && ok "node.sh: NODE_ARGS=(${node_args})" || fail "node.sh has no NODE_ARGS=(...) line" +eval "set -- ${node_args}" +launcher=$(tr -d '"' < "${ROOT}/scripts/k3d-gpu") # its words, as the shell passes them +for a in "$@"; do + grep -qF -- "${a}" <<< "${launcher}" && ok "node.sh: the launcher passes ${a}" \ + || fail "node.sh passes ${a}, which scripts/k3d-gpu's cluster create no longer does" +done +[[ " ${node_args} " == *"nvidia.com/gpu.present=false"* ]] && ok "node.sh: the node is labelled as having no NVIDIA GPU" \ + || fail "node.sh does not label the node nvidia.com/gpu.present=false" + +# The workflow runs the canary's scripts directly, so each must be executable. +scripts=$(grep -oE '(^|[[:space:]])canary/[A-Za-z0-9_.-]+\.(sh|py)' "${ROOT}/.github/workflows/canary.yml" | tr -d ' ' | sort -u) +[ -n "${scripts}" ] || fail "canary.yml runs no canary/ script" +for s in ${scripts}; do + [ -x "${ROOT}/${s}" ] && ok "canary.yml runs ${s}: executable" || fail "canary.yml runs ${s}, which is not executable" +done + +echo "${PASS} passed, ${FAILS} failed" +[ "${FAILS}" -eq 0 ] diff --git a/tests/canary_surface_test.py b/tests/canary_surface_test.py new file mode 100755 index 00000000..decbe1ea --- /dev/null +++ b/tests/canary_surface_test.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python3 +"""Tests for canary/surface.py: the flag and env names an engine image accepts, read +from its own help output or, for vLLM, statically from its installed sources, and +compared with the names k3d-gpu emits (canary/flags.sh). + +Fixtures (tests/fixtures/canary/README.md): a site-packages tree of vLLM v0.32.0's real +arg_utils.py, envs.py and launchers/cli_args.py, and the help output of the pinned +vLLM CPU, llama-server and OVMS images. + +Usage: python3 tests/canary_surface_test.py +""" + +import importlib.util +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +FIX = os.path.join(HERE, "fixtures", "canary") +VLLM = os.path.join(FIX, "vllm-0.32.0") +SURFACE = os.path.join(ROOT, "canary", "surface.py") + +spec = importlib.util.spec_from_file_location("surface", SURFACE) +surface = importlib.util.module_from_spec(spec) +spec.loader.exec_module(surface) + + +def read(path): + with open(path, encoding="utf-8") as f: + return f.read() + + +def flags_sh(*families): + out = subprocess.run([os.path.join(ROOT, "canary", "flags.sh"), *families], + check=True, capture_output=True, text=True).stdout + return [dict(zip(("family", "source", "kind", "name"), ln.split("\t"))) for ln in out.splitlines()] + + +def tree_copy(): + d = tempfile.mkdtemp() + shutil.copytree(VLLM, os.path.join(d, "site")) + return d, os.path.join(d, "site") + + +class StaticVllm(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.found = surface.static_package(VLLM, "vllm") + + def test_version_from_dist_info(self): + self.assertEqual(self.found["version"], "0.32.0+cpu") + self.assertEqual(surface.release(self.found["version"]), "0.32.0") + + def test_engine_and_frontend_flags(self): + flags = self.found["flags"] + for f in ("--served-model-name", "--max-model-len", "--enable-prefix-caching", "--dtype", + "--speculative-config", "--kv-cache-dtype", "--tensor-parallel-size", + # FrontendArgs fields (launchers/cli_args.py), named only as dataclass fields + "--tool-call-parser", "--enable-auto-tool-choice", "--port", "--host", + # make_arg_parser's own literals + "--headless", "--api-server-count"): + self.assertIn(f, flags, f) + self.assertGreater(len(flags), 200) + + def test_envs_from_environment_variables(self): + envs = self.found["envs"] + for e in ("VLLM_WORKER_MULTIPROC_METHOD", "VLLM_CPU_KVCACHE_SPACE", "VLLM_CACHE_ROOT", + "VLLM_ALLOW_LONG_MAX_MODEL_LEN", "VLLM_TARGET_DEVICE", "VLLM_NO_USAGE_STATS"): + self.assertIn(e, envs, e) + # removed from vLLM (the launcher's comment in VLLM_XPU_ENV says why it sets neither) + self.assertNotIn("VLLM_USE_V1", envs) + self.assertNotIn("VLLM_ATTENTION_BACKEND", envs) + + def test_inside_mode_finds_the_package_on_sys_path(self): + out = subprocess.run([sys.executable, SURFACE, "inside", "--pkg", "vllm"], capture_output=True, text=True, + env=dict(os.environ, PYTHONPATH=VLLM)) + self.assertEqual(out.returncode, 0, out.stderr) + doc = json.loads(out.stdout) + self.assertEqual(doc["version"], "0.32.0+cpu") + self.assertIn("--served-model-name", doc["flags"]) + self.assertIn("VLLM_CPU_KVCACHE_SPACE", doc["envs"]) + self.assertTrue(doc["root"].startswith(VLLM)) + + +class Compare(unittest.TestCase): + """The launcher's vLLM names against v0.32.0: the real cross-check the canary runs.""" + + @classmethod + def setUpClass(cls): + cls.found = surface.static_package(VLLM, "vllm") + cls.want = flags_sh("vllm") + + def test_launcher_vllm_names_all_exist_in_v0_32_0(self): + self.assertTrue(self.want) + missing = surface.missing(self.want, self.found["flags"], self.found["envs"], driver="vllm", lenient_no=True) + self.assertEqual(missing, []) + + def test_present_flag_passes(self): + want = [{"source": "t", "kind": "flag", "name": "--served-model-name"}] + self.assertEqual(surface.missing(want, self.found["flags"], set(), driver="vllm"), []) + + def test_underscore_spelling_is_the_same_vllm_flag(self): + want = [{"source": "t", "kind": "flag", "name": "--max_model_len"}] + self.assertEqual(surface.missing(want, self.found["flags"], set(), driver="vllm"), []) + + def test_unknown_flag_fails(self): + want = [{"source": "t", "kind": "flag", "name": "--no-such-flag"}] + self.assertEqual([m["name"] for m in surface.missing(want, self.found["flags"], set(), driver="vllm")], + ["--no-such-flag"]) + + def test_no_prefix_needs_the_positive_flag_statically(self): + want = [{"source": "t", "kind": "flag", "name": "--no-enable-prefix-caching"}] + self.assertEqual(surface.missing(want, self.found["flags"], set(), driver="vllm", lenient_no=True), []) + want = [{"source": "t", "kind": "flag", "name": "--no-such-thing"}] + self.assertEqual(len(surface.missing(want, self.found["flags"], set(), driver="vllm", lenient_no=True)), 1) + + def test_vllm_use_v1_fails(self): + want = [{"source": "t", "kind": "env", "name": "VLLM_USE_V1"}] + self.assertEqual([m["name"] for m in surface.missing(want, set(), self.found["envs"], driver="vllm")], + ["VLLM_USE_V1"]) + + def test_removed_flag_fails(self): + d, site = tree_copy() + try: + p = os.path.join(site, "vllm", "engine", "arg_utils.py") + text = read(p) + self.assertIn('"--served-model-name"', text) + with open(p, "w", encoding="utf-8") as f: + f.write(text.replace('"--served-model-name"', '"--served-name"')) + found = surface.static_package(site, "vllm") + want = [w for w in self.want if w["name"] == "--served-model-name"] + self.assertTrue(want) + self.assertEqual({m["name"] for m in surface.missing(want, found["flags"], found["envs"], driver="vllm")}, + {"--served-model-name"}) + finally: + shutil.rmtree(d) + + +class Unparseable(unittest.TestCase): + def mutate(self, rel, text=None): + d, site = tree_copy() + self.addCleanup(shutil.rmtree, d) + p = os.path.join(site, rel) + if text is None: + os.remove(p) + else: + with open(p, "w", encoding="utf-8") as f: + f.write(text) + return site + + def test_syntax_error(self): + site = self.mutate("vllm/engine/arg_utils.py", "def broken(:\n") + with self.assertRaises(surface.Unparseable): + surface.static_package(site, "vllm") + + def test_missing_envs(self): + site = self.mutate("vllm/envs.py") + with self.assertRaises(surface.Unparseable): + surface.static_package(site, "vllm") + + def test_envs_without_environment_variables(self): + site = self.mutate("vllm/envs.py", "VLLM_HOST_IP = ''\n") + with self.assertRaises(surface.Unparseable): + surface.static_package(site, "vllm") + + def test_no_cli_args_module(self): + site = self.mutate("vllm/entrypoints/launchers/cli_args.py") + with self.assertRaises(surface.Unparseable): + surface.static_package(site, "vllm") + + def test_no_add_argument_in_arg_utils(self): + site = self.mutate("vllm/engine/arg_utils.py", "x = 1\n") + with self.assertRaises(surface.Unparseable): + surface.static_package(site, "vllm") + + def test_no_package(self): + with self.assertRaises(surface.Unparseable): + surface.static_package(tempfile.gettempdir(), "vllm-not-here") + + def test_inside_mode_reports_unparseable(self): + site = self.mutate("vllm/envs.py") + out = subprocess.run([sys.executable, SURFACE, "inside", "--pkg", "vllm"], capture_output=True, text=True, + env=dict(os.environ, PYTHONPATH=site)) + self.assertEqual(out.returncode, 3) + self.assertIn("error", json.loads(out.stdout)) + + def test_empty_help_is_unparseable(self): + with self.assertRaises(surface.Unparseable): + surface.help_flags("usage: x\n\nno options here\n") + + +class Help(unittest.TestCase): + def test_vllm_help_matches_the_static_parse(self): + helped = surface.help_flags(read(os.path.join(FIX, "help", "vllm-0.32.0-cpu-serve-help-all.txt"))) + for f in ("--no-enable-prefix-caching", "--enable-prefix-caching", "--served-model-name", "-asc", "-sc", + "--speculative-config", "--tool-call-parser", "--enable-auto-tool-choice"): + self.assertIn(f, helped, f) + static = surface.static_package(VLLM, "vllm")["flags"] + # every flag the static parse names is one the parser really has + self.assertEqual(sorted(set(static) - helped), []) + missing = surface.missing(flags_sh("vllm"), helped, static_envs(), driver="vllm") + self.assertEqual(missing, []) + + def test_llama_server_help(self): + helped = surface.help_flags(read(os.path.join(FIX, "help", "llama-server-b11960-help.txt"))) + for f in ("-ngl", "--gpu-layers", "--n-gpu-layers", "-np", "--parallel", "--load-mode", "-c", + "--spec-type", "-md", "--spec-draft-model", "--reasoning", "--jinja", "--no-jinja", + "-ctk", "--cpu-moe", "--n-cpu-moe", "--model-url", "-hf", "--threads-batch"): + self.assertIn(f, helped, f) + self.assertEqual(surface.missing(flags_sh("llama.cpp"), helped, set(), driver="llama-server"), []) + + def test_ovms_help(self): + helped = surface.help_flags(read(os.path.join(FIX, "help", "ovms-2026.4.1-help.txt"))) + self.assertEqual(surface.missing(flags_sh("ovms"), helped, set(), driver="ovms"), []) + self.assertEqual([m["name"] for m in surface.missing([{"source": "t", "kind": "flag", "name": "--max-num-seqs"}], + helped, set(), driver="ovms")], ["--max-num-seqs"]) + + def test_sglang_help(self): + helped = surface.help_flags(read(os.path.join(FIX, "help", "sglang-0.5.22-xeon-help.txt"))) + for f in ("--model-path", "--tp-size", "--tensor-parallel-size", "--attention-backend", "--device", + "--max-total-tokens", "--served-model-name", "--host", "--port"): + self.assertIn(f, helped, f) + self.assertNotIn("--tp", helped) + # bench/h54's --tp is argparse's abbreviation of --tp-size, the one option it begins + self.assertEqual(surface.missing(flags_sh("sglang"), helped, set(), driver="sglang"), []) + self.assertEqual(surface.abbreviations(flags_sh("sglang"), helped, driver="sglang"), {"--tp": "--tp-size"}) + + def test_help_line_shapes(self): + text = ("usage: x [-h] [--a A]\n\noptions:\n -h, --help show\n" + " --enable-x, --no-enable-x\n --size SIZE, -s SIZE the size, see\n" + " --not-a-flag-in-prose\n-ngl, --gpu-layers, --n-gpu-layers N layers\n" + "--spec-type none,draft-mtp\n --rest_port REST_PORT REST port\n") + self.assertEqual(surface.help_flags(text), {"-h", "--help", "--enable-x", "--no-enable-x", "--size", "-s", + "-ngl", "--gpu-layers", "--n-gpu-layers", "--spec-type", + "--rest_port"}) + + +def static_envs(): + return surface.static_package(VLLM, "vllm")["envs"] + + +class Abbreviation(unittest.TestCase): + """sglang's parser is a plain argparse.ArgumentParser (allow_abbrev): a long option + may be shortened to any prefix that begins exactly one option string. In the + v0.5.22-xeon image `--tp 1` parses to tp_size 1 and `--t 1` is "ambiguous option".""" + + @classmethod + def setUpClass(cls): + cls.helped = surface.help_flags(read(os.path.join(FIX, "help", "sglang-0.5.22-xeon-help.txt"))) + + def names(self, *names, driver="sglang", flags=None): + want = [{"source": "t", "kind": "flag", "name": n} for n in names] + return [m["name"] for m in surface.missing(want, self.helped if flags is None else flags, set(), driver=driver)] + + def test_unique_prefix_is_that_option(self): + self.assertEqual(self.names("--tp", "--attention-back"), []) + + def test_ambiguous_prefix_fails(self): + self.assertEqual(self.names("--t"), ["--t"]) + + def test_prefix_of_no_option_fails(self): + self.assertEqual(self.names("--tpx", "--tp-sizes"), ["--tpx", "--tp-sizes"]) + + def test_single_dash_names_are_matched_exactly(self): + # k3d-gpu emits no single-dash sglang flag; only long prefixes are expanded + self.assertEqual(self.names("-", "-x", flags={"-xy", "--a", "--b"}), ["-", "-x"]) + + def test_a_new_option_with_the_prefix_makes_it_ambiguous(self): + self.assertEqual(self.names("--tp", flags=self.helped | {"--tp-rank"}), ["--tp"]) + + def test_vllm_takes_exact_names_only(self): + # the launcher spells vLLM flags out; a prefix match there would hide a rename + found = surface.static_package(VLLM, "vllm")["flags"] + self.assertEqual(self.names("--served-model", driver="vllm", flags=found), ["--served-model"]) + self.assertEqual(surface.abbreviations([{"source": "t", "kind": "flag", "name": "--served-model"}], found, + driver="vllm"), {}) + + +class Facts(unittest.TestCase): + def test_release_segment(self): + self.assertEqual(surface.release("0.32.0+cpu"), "0.32.0") + self.assertEqual(surface.release("0.26.0b2"), "0.26.0") + self.assertEqual(surface.release("v1.35.0"), "1.35.0") + self.assertEqual(surface.release("1.35.0.20260722-29947505341~ubuntu24.04"), "1.35.0") + self.assertIsNone(surface.release("nightly")) + + def test_setuptools_scm_build_of_a_tag_with_changes(self): + # setuptools-scm (guess-next-dev, node-and-date): a tree at tag X.Y.Z with + # uncommitted changes is X.Y.(Z+1).dev0+g<commit>.d<date>. intel/llm-scaler-vllm + # 1.5 reports 0.26.1.dev0+g568afb3a1.d20260907.xpu: vLLM v0.26.0's commit 568afb3a1 + # with Intel's patches applied, so its release is 0.26.0. + self.assertEqual(surface.release("0.26.1.dev0+g568afb3a1.d20260907.xpu"), "0.26.0") + self.assertEqual(surface.release("1.10.dev0+g0123abc.d20260101"), "1.9") + # commits after the tag, or no scm node: not the tag + self.assertEqual(surface.release("0.26.1.dev3+g568afb3a1.d20260907"), "0.26.1") + self.assertEqual(surface.release("0.26.1.dev0"), "0.26.1") + # a next version ending in 0 came from a scheme other than guess-next-dev's + # last-segment bump: its tag is unknown, and an unknown release matches nothing + self.assertIsNone(surface.release("0.26.0.dev0+g568afb3a1.d20260907")) + derived = [{"file": "console/plan.py", "of": "package", "value": "0.26.0"}] + self.assertEqual(surface.derived_mismatch(derived, {"package": "0.26.1.dev0+g568afb3a1.d20260907.xpu"}), []) + self.assertEqual(len(surface.derived_mismatch(derived, {"package": "0.26.1.dev2+g1a2b3c4.d20260907"})), 1) + + def test_firmware_build_date(self): + self.assertEqual(surface.fw_date(b"\x00junk 20260630*MTL_CLIENT_SILICON-release"), "20260630") + self.assertEqual(surface.fw_date(b"\x00Jun 30 2026*NPU40xx*more"), "20260630") + self.assertEqual(surface.fw_date(b"\x00Jan 5 2026*NPU50xx*"), "20260105") + self.assertIsNone(surface.fw_date(b"no date here")) + + def test_gst_properties(self): + text = ("Factory Details:\n Rank none (0)\n\nElement Properties:\n\n" + " device : Target device for inference\n flags: readable\n" + " model-proc : Path to JSON file\n pre-process-backend : Preprocessing\n\n" + "Element Signals:\n \"handoff\" : void user_function\n") + self.assertEqual(surface.gst_properties(text), {"device", "model-proc", "pre-process-backend"}) + + def test_derived_mismatch_is_named(self): + derived = [{"file": "console/plan.py", "of": "package", "value": "0.31.0"}, + {"file": "console/model_opt.py", "of": "package", "value": "0.32.0"}] + bad = surface.derived_mismatch(derived, {"package": "0.32.0+cpu"}) + self.assertEqual([d["file"] for d in bad], ["console/plan.py"]) + self.assertEqual(surface.derived_mismatch(derived[1:], {"package": "0.32.0+cpu"}), []) + # a fact the image did not give is a mismatch, never a pass + self.assertEqual(len(surface.derived_mismatch(derived[1:], {})), 1) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/canary_test.sh b/tests/canary_test.sh new file mode 100755 index 00000000..db0b598e --- /dev/null +++ b/tests/canary_test.sh @@ -0,0 +1,351 @@ +#!/usr/bin/env bash +# canary/run.sh against stub engines: tests/openai_stub.py and tests/fake_ovms.py play +# the server, a docker shim on PATH plays the daemon (pull, run, inspect, logs, rm) and +# serves the pinned images' real help output (tests/fixtures/canary/help) and vLLM +# v0.32.0's real sources for the surface check. No image is pulled, nothing listens +# beyond 127.0.0.1, and each case reads the result JSON canary/run.sh writes. +# +# Usage: tests/canary_test.sh + +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +FIX=${ROOT}/tests/fixtures/canary +T=$(mktemp -d "${TMPDIR:-/tmp}/canary_test.XXXXXX") +PASS=0 FAILS=0 +ok() { PASS=$((PASS + 1)); echo "ok $1"; } +fail() { FAILS=$((FAILS + 1)); echo "FAIL $1"; } +cleanup() { + local f + for f in "${T}"/state/*.pid; do + [ -f "${f}" ] && kill -KILL -- "-$(cat "${f}")" 2>/dev/null + done + [ -z "${HF_PID:-}" ] || kill "${HF_PID}" 2>/dev/null + rm -rf "${T}" +} +trap cleanup EXIT +mkdir -p "${T}/bin" "${T}/state" "${T}/dockerroot" + +# The docker shim. STUB_SERVER is the command a detached `docker run` starts ({port} is +# the published host port); STUB_HELP the text --help prints; STUB_VERSION_TEXT what +# --version prints; STUB_SITE the site-packages tree `surface.py inside` reads; +# STUB_PULL_FAILS how many pulls fail first; STUB_IMAGE_BYTES the compressed image size. +cat > "${T}/bin/docker" <<'SHIM' +#!/usr/bin/env bash +S=${STUB_STATE:?} +echo "docker $*" >> "${S}/calls" +case "$1" in + info) echo "${STUB_DOCKER_ROOT:?}"; exit 0 ;; + buildx) + printf '{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","layers":[{"size":%s}]}\n' \ + "${STUB_IMAGE_BYTES:-1000000}" + exit 0 ;; + pull) + n=$(cat "${S}/pullfails" 2>/dev/null || echo "${STUB_PULL_FAILS:-0}") + if [ "${n}" -gt 0 ]; then echo $((n - 1)) > "${S}/pullfails"; echo "pull: toomanyrequests" >&2; exit 1; fi + echo 0 > "${S}/pullfails" + exit 0 ;; + inspect) + name=${*: -1} + if [ -f "${S}/${name}.pid" ] && kill -0 "$(cat "${S}/${name}.pid")" 2>/dev/null; then echo "true 0" + else echo "false $(cat "${S}/${name}.rc" 2>/dev/null || echo 0)"; fi + exit 0 ;; + logs) cat "${S}/${*: -1}.log" 2>/dev/null; exit 0 ;; + rm) + name=${*: -1} + [ -f "${S}/${name}.pid" ] && kill -KILL -- "-$(cat "${S}/${name}.pid")" 2>/dev/null + rm -f "${S}/${name}.pid" + exit 0 ;; + run) shift ;; + *) echo "shim: unhandled docker $*" >&2; exit 1 ;; +esac +detach="" name="" port="" canary="" +while [ $# -gt 0 ]; do + case "$1" in + -d) detach=1; shift ;; + --rm|--init) shift ;; + --name) name=$2; shift 2 ;; + -p) port=${2#127.0.0.1:}; port=${port%%:*}; shift 2 ;; + -v) case "$2" in *:/canary:ro) canary=${2%%:/canary:ro} ;; esac; shift 2 ;; + -e|--entrypoint|--cap-add|--user|--ulimit|--cpus|--shm-size|--network) shift 2 ;; + -*) shift ;; + *) break ;; + esac +done +shift # the image +if [ -n "${detach}" ]; then + # Its own process group (setsid), whose leader writes the pid file: `rm` kills the group. + cmd=${STUB_SERVER//\{port\}/${port}} + ( setsid bash -c 'echo $$ > "$0"; eval "$1"' "${S}/${name}.pid" "${cmd}" > "${S}/${name}.log" 2>&1 + echo $? > "${S}/${name}.rc" ) > /dev/null 2>&1 & + for _ in $(seq 50); do [ -s "${S}/${name}.pid" ] && break; sleep 0.05; done + echo "cid-${name}" + exit 0 +fi +case " $* " in + *" /canary/surface.py inside "*) shift; PYTHONPATH=${STUB_SITE:?} exec python3 "${canary}/surface.py" "$@" ;; + *" --help"*) cat "${STUB_HELP:?}"; exit 0 ;; + *" --version "*) echo "${STUB_VERSION_TEXT:-}"; exit 0 ;; + *) exit 0 ;; +esac +SHIM +chmod +x "${T}/bin/docker" + +export PATH="${T}/bin:${PATH}" +export STUB_STATE=${T}/state STUB_DOCKER_ROOT=${T}/dockerroot +export CANARY_MODELS=${T}/models CANARY_PULL_BACKOFF=0 CANARY_READY_TIMEOUT=20 +printf 'processor\t: 0\nflags\t\t: fpu sse2 avx avx2 avx512f amx_tile\n' > "${T}/cpuinfo.avx512" +printf 'processor\t: 0\nflags\t\t: fpu sse2 avx avx2\n' > "${T}/cpuinfo.avx2" +export CANARY_CPUINFO=${T}/cpuinfo.avx512 + +# Every runtime engine's model, already in the cache (as actions/cache restores it). +for id in vllm-cpu ovms-cpu llama-cpu eat-vllm-cpu; do + d=$(python3 "${ROOT}/canary/plan.py" model-dir "${id}") + mkdir -p "${d}" + : > "${d}/placeholder" + python3 "${ROOT}/canary/plan.py" show "${id}" | python3 -c 'import json, sys; print(json.load(sys.stdin)["revision"])' > "${d}/.complete" +done + +STUB=${ROOT}/tests/openai_stub.py +GOLD=" Paris. The capital of Italy is Rome" +LLAMA_HELP=${FIX}/help/llama-server-b11960-help.txt +VLLM_HELP=${FIX}/help/vllm-0.32.0-cpu-serve-help-all.txt + +# The versions the stubs report are the ones the tree's pins name (canary/plan.py show), +# so a bump of any of these pins leaves the cases below as they are. pinned <id> <key> +# prints the engine's expected_version (key v) or its first derived constant (key d). +pinned() { + python3 "${ROOT}/canary/plan.py" show "$1" | python3 -c 'import json, sys +d = json.load(sys.stdin) +print(d["expected_version"] if sys.argv[1] == "v" else d["derived"][0]["value"])' "$2" +} +LLAMA_V=$(pinned llama-cpu v) # llama.cpp build number, from server-b<N> +VLLM_V=$(pinned vllm-cpu v) # X.Y.Z, from vX.Y.Z +EAT_V=$(pinned eat-vllm-cpu v) +OVMS_V=$(pinned ovms-cpu v) # empty while the pin has no tag: nothing is compared +SGLANG_V=$(pinned sglang-xeon v) +SCALER_V=$(pinned llm-scaler d) # console/plan.py's llm-scaler vLLM version +for v in LLAMA_V VLLM_V EAT_V SGLANG_V SCALER_V; do + [ -n "${!v}" ] || fail "${v}: the tree's pin names no version; the cases below need one" +done +LLAMA_OK="--build-info b${LLAMA_V}-8642541b2" + +# run_case <label> <engine> [VAR=value...]: canary/run.sh <engine> in a fresh state and +# output directory; leaves the result in RESULT and run.sh's exit status in RC. +run_case() { + local label=$1 id=$2 f + shift 2 + for f in "${T}"/state/*.pid; do + [ -f "${f}" ] && kill -KILL -- "-$(cat "${f}")" 2>/dev/null + done + rm -rf "${T}/state" "${T}/out" + mkdir -p "${T}/state" + START=${SECONDS} + env "$@" CANARY_OUT_DIR="${T}/out" "${ROOT}/canary/run.sh" "${id}" > "${T}/${label}.log" 2>&1 + RC=$? + ELAPSED=$((SECONDS - START)) + RESULT=${T}/out/${id}.json + [ -f "${RESULT}" ] || { fail "${label}: no ${id}.json (run.sh rc=${RC}); log:"; sed 's/^/ /' "${T}/${label}.log" | tail -20; return 1; } +} +field() { python3 -c 'import json, sys; v = json.load(open(sys.argv[1]))[sys.argv[2]]; print(json.dumps(v) if not isinstance(v, str) else v)' "${RESULT}" "$1"; } +expect() { # expect <label> <field> <value> + local got + got=$(field "$2") + if [ "${got}" = "$3" ]; then ok "$1: $2=$3"; else fail "$1: $2=${got}, want $3 ($(field detail))"; fi +} + +# 1. A good server: hard, exact, version, stream; the surface check reads the help output. +run_case good llama-cpu STUB_HELP="${LLAMA_HELP}" \ + STUB_SERVER="python3 ${STUB} --port {port} --text '${GOLD}' ${LLAMA_OK}" +expect good verdict pass +expect good hard true +expect good exact true +expect good kind runtime +expect good cpu_flags '["avx2", "avx512f", "amx_tile"]' +want_ref=$(python3 "${ROOT}/canary/plan.py" ref llama-cpu) +expect good pinned_ref "${want_ref}" +[ "${RC}" -eq 0 ] && ok "good: run.sh exits 0" || fail "good: run.sh exited ${RC}" +keys=$(python3 -c 'import json, sys; print(" ".join(sorted(json.load(open(sys.argv[1])))))' "${RESULT}") +[ "${keys}" = "cpu_flags detail exact hard kind pinned_ref verdict" ] && ok "good: result has exactly the interface keys" \ + || fail "good: result keys are '${keys}'" +grep -q -- '--threads 4\|--threads [0-9]' "${STUB_STATE}/calls" && ok "good: {threads} is substituted" \ + || fail "good: no --threads N in the docker run call" +grep -qE -- "--cap-add IPC_LOCK" "${STUB_STATE}/calls" && ok "good: llama-cpu runs with IPC_LOCK (mlock)" \ + || fail "good: llama-cpu docker run has no --cap-add IPC_LOCK" +grep -q '^docker rm -f canary-llama-cpu' "${STUB_STATE}/calls" && ok "good: the engine container is removed" \ + || fail "good: no docker rm -f of the engine container" + +# 2. Empty content: no text and no tokens is a failure. +run_case empty llama-cpu STUB_HELP="${LLAMA_HELP}" \ + STUB_SERVER="python3 ${STUB} --port {port} --text '' ${LLAMA_OK}" +expect empty verdict fail +expect empty hard false +[ "${RC}" -ne 0 ] && ok "empty: run.sh exits non-zero" || fail "empty: run.sh exited 0" + +# 3. A stream that never sends [DONE]. +run_case nodone llama-cpu STUB_HELP="${LLAMA_HELP}" \ + STUB_SERVER="python3 ${STUB} --port {port} --text '${GOLD}' ${LLAMA_OK} --no-done" +expect nodone verdict fail +grep -q 'DONE' <(field detail) && ok "nodone: detail names [DONE]" || fail "nodone: detail is '$(field detail)'" + +# 4. The server reports another build than the pinned tag. +run_case version llama-cpu STUB_HELP="${LLAMA_HELP}" \ + STUB_SERVER="python3 ${STUB} --port {port} --text '${GOLD}' --build-info b$((LLAMA_V - 1))-0000000" +expect version verdict fail +grep -q "b$((LLAMA_V - 1))-" <(field detail) && ok "version: detail names the server's version" || fail "version: detail is '$(field detail)'" + +# 5. A golden mismatch keeps hard and clears exact. +run_case golden llama-cpu STUB_HELP="${LLAMA_HELP}" \ + STUB_SERVER="python3 ${STUB} --port {port} --text ' Paris, of course, is' ${LLAMA_OK}" +expect golden verdict pass +expect golden hard true +expect golden exact false + +# 6. A server that never lists its model fails at the timeout, not later. +run_case never llama-cpu STUB_HELP="${LLAMA_HELP}" CANARY_READY_TIMEOUT=3 \ + STUB_SERVER="python3 ${STUB} --port {port} --never-ready" +expect never verdict fail +[ "${ELAPSED}" -le 30 ] && ok "never: gave up within the timeout (${ELAPSED}s)" || fail "never: took ${ELAPSED}s" +grep -qi 'ready' <(field detail) && ok "never: detail says it never became ready" || fail "never: detail is '$(field detail)'" + +# 6b. A probe that writes no result (here: it cannot parse its timeout) is a failure, never a pass. +run_case noprobe llama-cpu STUB_HELP="${LLAMA_HELP}" CANARY_READY_TIMEOUT=soon \ + STUB_SERVER="python3 ${STUB} --port {port} --text '${GOLD}' ${LLAMA_OK}" +expect noprobe verdict fail +expect noprobe hard false + +# 7. OVMS: the pull step, then /v3 on fake_ovms.py, reporting the version its pin's tag +# names (a pin with no tag is not compared). +run_case ovms ovms-cpu FAKE_OVMS_TEXT="${GOLD}" FAKE_OVMS_VERSION="${OVMS_V:-2026.4.1}.cfc9ac721" \ + STUB_SERVER="python3 ${ROOT}/tests/fake_ovms.py {port}" +expect ovms verdict pass +expect ovms exact true +grep -q -- '--pull --source_model=OpenVINO/Qwen3-0.6B-int8-ov' "${STUB_STATE}/calls" \ + && ok "ovms: the pull step runs first" || fail "ovms: no --pull run before the server" +grep -q 'HF_ENDPOINT=http://127.0.0.1:9' "${STUB_STATE}/calls" && ok "ovms: the pull step cannot fetch" \ + || fail "ovms: the pull step is not offline" + +# 8. vLLM: help output and the static parse of v0.32.0's sources both pass. +VLLM_OK=(STUB_HELP="${VLLM_HELP}" STUB_SITE="${FIX}/vllm-0.32.0" + STUB_SERVER="python3 ${STUB} --port {port} --text '${GOLD}' --version ${VLLM_V}+cpu") +run_case vllm vllm-cpu "${VLLM_OK[@]}" +expect vllm verdict pass +expect vllm hard true + +# 9. A flag the launcher emits that the image's help lacks fails the surface check. +grep -v -- '--served-model-name' "${VLLM_HELP}" > "${T}/vllm-help-missing.txt" +run_case vllm-flag vllm-cpu "${VLLM_OK[@]}" STUB_HELP="${T}/vllm-help-missing.txt" +expect vllm-flag verdict fail +grep -q -- '--served-model-name' <(field detail) && ok "vllm-flag: detail names the flag" || fail "vllm-flag: detail is '$(field detail)'" + +# 10. Sources the static parse cannot read are unparseable, never skipped. +cp -r "${FIX}/vllm-0.32.0" "${T}/broken-site" +rm "${T}/broken-site/vllm/envs.py" +run_case vllm-unparseable vllm-cpu "${VLLM_OK[@]}" STUB_SITE="${T}/broken-site" +expect vllm-unparseable verdict unparseable +expect vllm-unparseable hard false + +# 11. An engine killed by SIGILL on a runner without AVX-512 is isa-skip; with it, a failure. +run_case isa vllm-cpu "${VLLM_OK[@]}" CANARY_CPUINFO="${T}/cpuinfo.avx2" \ + STUB_SERVER="echo 'Illegal instruction (core dumped)'; exit 132" +expect isa verdict isa-skip +expect isa hard false +expect isa cpu_flags '["avx2"]' +run_case isa512 vllm-cpu "${VLLM_OK[@]}" STUB_SERVER="echo 'Illegal instruction (core dumped)'; exit 132" +expect isa512 verdict fail + +# 12. An image larger than the free disk is too-big, and is never pulled. +run_case big llama-cpu STUB_HELP="${LLAMA_HELP}" STUB_IMAGE_BYTES=900000000000 CANARY_FREE_BYTES=1000000000 \ + STUB_SERVER="python3 ${STUB} --port {port} --text '${GOLD}' ${LLAMA_OK}" +expect big verdict too-big +! grep -q '^docker pull' "${STUB_STATE}/calls" && ok "big: no pull" || fail "big: pulled anyway" + +# 13. Pulls retry: two failures then success passes; three failures fail. +run_case pull2 llama-cpu STUB_HELP="${LLAMA_HELP}" STUB_PULL_FAILS=2 \ + STUB_SERVER="python3 ${STUB} --port {port} --text '${GOLD}' ${LLAMA_OK}" +expect pull2 verdict pass +[ "$(grep -c '^docker pull' "${STUB_STATE}/calls")" -eq 3 ] && ok "pull2: three attempts" || fail "pull2: $(grep -c '^docker pull' "${STUB_STATE}/calls") attempts" +run_case pull3 llama-cpu STUB_HELP="${LLAMA_HELP}" STUB_PULL_FAILS=3 \ + STUB_SERVER="python3 ${STUB} --port {port} --text '${GOLD}' ${LLAMA_OK}" +expect pull3 verdict fail +grep -qi 'pull' <(field detail) && ok "pull3: detail names the pull" || fail "pull3: detail is '$(field detail)'" + +# 14. An informational engine's failure does not fail run.sh. +run_case eat eat-vllm-cpu "${VLLM_OK[@]}" STUB_SERVER="python3 ${STUB} --port {port} --text '' --version ${EAT_V}" +expect eat verdict fail +[ "${RC}" -eq 0 ] && ok "eat: informational, run.sh exits 0" || fail "eat: run.sh exited ${RC}" + +# 15. A model not in the cache is fetched at the pinned revision, then reused. +id=llama-cpu +rev=$(python3 "${ROOT}/canary/plan.py" show "${id}" | python3 -c 'import json, sys; print(json.load(sys.stdin)["revision"])') +mkdir -p "${T}/hf/ggml-org/Qwen3-0.6B-GGUF/resolve/${rev}" +echo gguf-bytes > "${T}/hf/ggml-org/Qwen3-0.6B-GGUF/resolve/${rev}/Qwen3-0.6B-Q8_0.gguf" +python3 -u -m http.server 0 --bind 127.0.0.1 --directory "${T}/hf" > "${T}/hf.log" 2>&1 & +HF_PID=$! +for _ in $(seq 50); do grep -q 'Serving HTTP' "${T}/hf.log" && break; sleep 0.1; done +HF_PORT=$(sed -n 's/.*port \([0-9]*\).*/\1/p' "${T}/hf.log" | head -1) +run_case fetch llama-cpu CANARY_MODELS="${T}/fresh" HF_ENDPOINT="http://127.0.0.1:${HF_PORT}" STUB_HELP="${LLAMA_HELP}" \ + STUB_SERVER="python3 ${STUB} --port {port} --text '${GOLD}' ${LLAMA_OK}" +expect fetch verdict pass +d=$(CANARY_MODELS="${T}/fresh" python3 "${ROOT}/canary/plan.py" model-dir "${id}") +[ "$(cat "${d}/Qwen3-0.6B-Q8_0.gguf" 2>/dev/null)" = gguf-bytes ] && ok "fetch: the file came from <model>/resolve/<revision>" \ + || fail "fetch: ${d}/Qwen3-0.6B-Q8_0.gguf missing" +[ "$(cat "${d}/.complete" 2>/dev/null)" = "${rev}" ] && ok "fetch: .complete names the revision" || fail "fetch: no .complete" +kill "${HF_PID}" 2>/dev/null +wait "${HF_PID}" 2>/dev/null +HF_PID="" +run_case cached llama-cpu CANARY_MODELS="${T}/fresh" HF_ENDPOINT="http://127.0.0.1:${HF_PORT}" STUB_HELP="${LLAMA_HELP}" \ + STUB_SERVER="python3 ${STUB} --port {port} --text '${GOLD}' ${LLAMA_OK}" +expect cached verdict pass + +# run_surface <label> <engine> [VAR=value...]: canary/run.sh --surface <engine>, as run_case. +run_surface() { + local label=$1 id=$2 + shift 2 + rm -rf "${T}/state" "${T}/out" + mkdir -p "${T}/state" + env "$@" CANARY_OUT_DIR="${T}/out" "${ROOT}/canary/run.sh" --surface "${id}" > "${T}/${label}.log" 2>&1 + RC=$? + RESULT=${T}/out/${id}.json + [ -f "${RESULT}" ] || { fail "${label}: no ${id}.json (run.sh rc=${RC}); log:"; sed 's/^/ /' "${T}/${label}.log" | tail -20; return 1; } +} + +# 16. sglang: bench/h54's --tp is argparse's abbreviation of --tp-size, the only option +# it begins; once a second option begins with it, argparse calls it ambiguous. The +# v0.5.22 site reports the version the tree's pin names. +SGLANG_HELP=${FIX}/help/sglang-0.5.22-xeon-help.txt +cp -r "${FIX}/sglang-0.5.22" "${T}/sglang-site" +sed -i -E "s/^(__version__ = version = )'[^']*'/\1'${SGLANG_V}'/" "${T}/sglang-site/sglang/_version.py" +run_surface sglang sglang-xeon STUB_HELP="${SGLANG_HELP}" STUB_SITE="${T}/sglang-site" +expect sglang verdict pass +expect sglang kind surface +grep -q -- "--tp is argparse's abbreviation of --tp-size" <(field detail) && ok "sglang: detail names the abbreviation" \ + || fail "sglang: detail is '$(field detail)'" +grep -qF "version ${SGLANG_V}" <(field detail) && ok "sglang: version ${SGLANG_V} from sglang/_version.py" \ + || fail "sglang: detail is '$(field detail)'" +{ cat "${SGLANG_HELP}"; echo " --tp-rank TP_RANK a new option"; } > "${T}/sglang-help-ambiguous.txt" +run_surface sglang-ambiguous sglang-xeon STUB_HELP="${T}/sglang-help-ambiguous.txt" STUB_SITE="${T}/sglang-site" +expect sglang-ambiguous verdict fail +grep -q -- '--tp (bench/h54/h54.py:sglang_args)' <(field detail) && ok "sglang-ambiguous: detail names --tp" \ + || fail "sglang-ambiguous: detail is '$(field detail)'" + +# 17. llm-scaler: its vLLM reports the setuptools-scm version of a tag with patches +# (intel/llm-scaler-vllm 1.5: 0.26.1.dev0+g568afb3a1.d<date>, vLLM v0.26.0's commit), +# which the X.Y.Z the tree derives (SCALER_V) matches; a build commits after a tag does not. +scaler_site() { # scaler_site <version>: the v0.32.0 tree, reporting <version> + rm -rf "${T}/scaler-site" + cp -r "${FIX}/vllm-0.32.0" "${T}/scaler-site" + sed -i "s/^Version: .*/Version: $1/" "${T}/scaler-site/vllm-0.32.0+cpu.dist-info/METADATA" +} +SCALER_NEXT=${SCALER_V%.*}.$((${SCALER_V##*.} + 1)) # setuptools-scm's next version of the tag +scaler_site "${SCALER_NEXT}.dev0+g568afb3a1.d20260907.xpu" +run_surface scaler llm-scaler STUB_HELP="${VLLM_HELP}" STUB_SITE="${T}/scaler-site" +expect scaler verdict pass +scaler_site "${SCALER_NEXT}.dev4+g1a2b3c4d.d20260907.xpu" +run_surface scaler-ahead llm-scaler STUB_HELP="${VLLM_HELP}" STUB_SITE="${T}/scaler-site" +expect scaler-ahead verdict fail +grep -qF "console/plan.py says ${SCALER_V}," <(field detail) && ok "scaler-ahead: detail names console/plan.py" \ + || fail "scaler-ahead: detail is '$(field detail)'" + +echo "${PASS} passed, ${FAILS} failed" +[ "${FAILS}" -eq 0 ] diff --git a/tests/chat_ui_test.sh b/tests/chat_ui_test.sh index 94f1a5c6..45fb286f 100755 --- a/tests/chat_ui_test.sh +++ b/tests/chat_ui_test.sh @@ -37,14 +37,14 @@ expect_env() { [ -f "${MANIFEST}" ] || { echo "FAIL manifest missing: ${MANIFEST}"; exit 1; } -# Image pinned by digest, never by a moving tag. -has "image pinned by digest" '^ +image: ghcr\.io/open-webui/open-webui@sha256:[0-9a-f]{64}$' +# Image pinned by digest under its version tag, never by a moving tag alone. +has "image pinned by digest" '^ +image: ghcr\.io/open-webui/open-webui:v[0-9.]+@sha256:[0-9a-f]{64}$' lacks "no tag-only image" '^ +image: [^@]+:[^@]+$' # The exact index digest of ghcr.io/open-webui/open-webui:v0.12.2, and the # version the header comment names. A bump changes all three together. OWUI_VERSION=v0.12.2 OWUI_DIGEST=sha256:10e6a78ba2d172493460a9e8a92c496aaa7a64dc4d26d622a2e95e0c736081c9 -has "image is ${OWUI_VERSION} digest" "^ +image: ghcr\.io/open-webui/open-webui@${OWUI_DIGEST}$" +has "image is ${OWUI_VERSION} digest" "^ +image: ghcr\.io/open-webui/open-webui:${OWUI_VERSION//./\\.}@${OWUI_DIGEST}$" has "header names ${OWUI_VERSION}" "^# Source: https://github\.com/open-webui/open-webui ${OWUI_VERSION//./\\.}\." # E-core placement through the NRI balloons label, sized by a CPU request. diff --git a/tests/ci_alarm_test.sh b/tests/ci_alarm_test.sh new file mode 100755 index 00000000..be2c3c2d --- /dev/null +++ b/tests/ci_alarm_test.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +# hack/ci_alarm against tests/ops/fake_gh.py on PATH (an issue store in a temp +# directory, no network). Checks: open files one labelled, assigned issue with an +# @mention per key; the same body within 24 h adds nothing; a changed body +# comments and replaces the body; the same body after 24 h comments once more; +# close comments and closes; list prints the open keys; a repository with issues +# disabled gets a workflow error annotation and exit 3 instead. +# +# Usage: tests/ci_alarm_test.sh + +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ALARM=${ALARM:-${ROOT}/hack/ci_alarm} +T=$(mktemp -d "${TMPDIR:-/tmp}/ci_alarm_test.XXXXXX") +trap 'rm -rf "$T"' EXIT +PASS=0 FAILS=0 +ok() { PASS=$((PASS + 1)); echo "ok $1"; } +fail() { FAILS=$((FAILS + 1)); echo "FAIL $1"; } + +mkdir -p "$T/bin" +printf '#!/bin/sh\nexec python3 %q "$@"\n' "${ROOT}/tests/ops/fake_gh.py" > "$T/bin/gh" +chmod +x "$T/bin/gh" +export PATH="$T/bin:$PATH" GH_REPO=88plug/k3d-gpu +unset GITHUB_STEP_SUMMARY ALARM_OWNER + +# fresh NAME: a new empty issue store; the clock at 2026-10-29T13:00:00Z. +fresh() { + export FAKE_GH="$T/$1" + mkdir -p "$FAKE_GH" + clock 2026-10-29T13:00:00Z +} +clock() { export FAKE_GH_NOW=$1; CI_ALARM_NOW=$(date -u -d "$1" +%s); export CI_ALARM_NOW; } +body() { printf '%s\n' "$@" > "$T/body"; echo "$T/body"; } +alarm() { bash "$ALARM" "$@" > "$FAKE_GH/out" 2> "$FAKE_GH/err"; } +issues() { jq "$@" "$FAKE_GH/issues.json"; } +calls() { grep -c "^issue $1" "$FAKE_GH/calls.log" 2>/dev/null || true; } + +# 1. open: one issue, titled, labelled ci-alarm, assigned to 88plug, @mention in the body. +fresh open +alarm open red:build-and-push.yml "nightly is red" "$(body "run 39412124435 failed")"; rc=$? +if [ "$rc" -eq 0 ] \ + && [ "$(issues length)" = 1 ] \ + && [ "$(issues -r '.[0].title')" = "[ci-alarm] red:build-and-push.yml: nightly is red" ] \ + && [ "$(issues -r '.[0].labels[0].name')" = ci-alarm ] \ + && [ "$(issues -r '.[0].assignees[0].login')" = 88plug ] \ + && issues -r '.[0].body' | grep -q '@88plug' \ + && issues -r '.[0].body' | grep -q 'run 39412124435 failed' \ + && jq -e 'index("ci-alarm")' "$FAKE_GH/labels.json" >/dev/null; then + ok "open files one labelled, assigned issue with an @mention" +else + fail "open: rc=$rc issues=$(cat "$FAKE_GH/issues.json" 2>/dev/null) err=$(cat "$FAKE_GH/err")" +fi + +# 2. the same body within 24 h: no comment, no edit, no second issue. +clock 2026-10-29T20:00:00Z +before=$(calls comment) +alarm open red:build-and-push.yml "nightly is red" "$(body "run 39412124435 failed")"; rc=$? +if [ "$rc" -eq 0 ] && [ "$(issues length)" = 1 ] && [ "$(issues '.[0].comments | length')" = 0 ] \ + && [ "$(calls comment)" = "$before" ] && [ "$(calls edit)" = 0 ]; then + ok "the same body within 24 h adds nothing" +else + fail "same body within 24 h: rc=$rc comments=$(issues '.[0].comments | length') edits=$(calls edit)" +fi + +# 3. a changed body: one comment carrying it, and the issue body replaced. +alarm open red:build-and-push.yml "nightly is red" "$(body "run 39591066580 failed")"; rc=$? +if [ "$rc" -eq 0 ] && [ "$(issues length)" = 1 ] && [ "$(issues '.[0].comments | length')" = 1 ] \ + && issues -r '.[0].comments[0].body' | grep -q 'run 39591066580 failed' \ + && issues -r '.[0].body' | grep -q 'run 39591066580 failed' \ + && ! issues -r '.[0].body' | grep -q 'run 39412124435'; then + ok "a changed body comments and replaces the issue body" +else + fail "changed body: rc=$rc issues=$(cat "$FAKE_GH/issues.json")" +fi + +# 4. the same body 24 h after the last comment: one reminder comment, then quiet again. +clock 2026-10-30T20:00:01Z +alarm open red:build-and-push.yml "nightly is red" "$(body "run 39591066580 failed")"; rc1=$? +alarm open red:build-and-push.yml "nightly is red" "$(body "run 39591066580 failed")"; rc2=$? +if [ "$rc1" -eq 0 ] && [ "$rc2" -eq 0 ] && [ "$(issues '.[0].comments | length')" = 2 ] \ + && issues -r '.[0].comments[1].body' | grep -q '@88plug'; then + ok "the same body after 24 h comments once, with an @mention" +else + fail "24 h reminder: rc=$rc1/$rc2 comments=$(issues '.[0].comments | length')" +fi + +# 5. a second key is its own issue; a key that extends another is not confused with it. +alarm open red:build-and-push.yml.bak "other" "$(body x)"; rc1=$? +alarm open release:0.1.76 "no image" "$(body y)"; rc2=$? +if [ "$rc1" -eq 0 ] && [ "$rc2" -eq 0 ] && [ "$(issues length)" = 3 ]; then + ok "each key gets its own issue" +else + fail "keys: rc=$rc1/$rc2 issues=$(issues length)" +fi + +# 6. list prints the open keys, one per line. +alarm list; rc=$? +if [ "$rc" -eq 0 ] && [ "$(sort "$FAKE_GH/out" | tr '\n' ' ')" = "red:build-and-push.yml red:build-and-push.yml.bak release:0.1.76 " ]; then + ok "list prints the open keys" +else + fail "list: rc=$rc out=$(tr '\n' ' ' < "$FAKE_GH/out")" +fi + +# 7. close comments and closes; list no longer prints the key; closing again is a no-op. +alarm close red:build-and-push.yml "the nightly is green again"; rc1=$? +n=$(issues -r '.[] | select(.title | startswith("[ci-alarm] red:build-and-push.yml:")) | .number') +# shellcheck disable=SC2016 # $n is a jq variable +st=$(issues -r --argjson n "$n" '.[] | select(.number == $n) | .state') +# shellcheck disable=SC2016 +last=$(issues -r --argjson n "$n" '.[] | select(.number == $n) | .comments[-1].body') +alarm close red:build-and-push.yml "again"; rc2=$? +alarm list +if [ "$rc1" -eq 0 ] && [ "$rc2" -eq 0 ] && [ "$st" = CLOSED ] && [ "$last" = "the nightly is green again" ] \ + && ! grep -qx 'red:build-and-push.yml' "$FAKE_GH/out" && grep -qx 'release:0.1.76' "$FAKE_GH/out" \ + && [ "$(issues '[.[] | select(.state == "OPEN")] | length')" = 2 ]; then + ok "close comments and closes; a second close is a no-op" +else + fail "close: rc=$rc1/$rc2 state=$st last=$last" +fi + +# 8. reopening a closed key files a new issue. +alarm open red:build-and-push.yml "red again" "$(body z)"; rc=$? +if [ "$rc" -eq 0 ] && [ "$(issues '[.[] | select(.title | startswith("[ci-alarm] red:build-and-push.yml:"))] | length')" = 2 ]; then + ok "a key opened after close files a new issue" +else + fail "reopen: rc=$rc" +fi + +# 9. ALARM_OWNER moves the assignee and the mention. +fresh owner +ALARM_OWNER=someone-else alarm open simulate "simulated red" "$(body s)"; rc=$? +if [ "$rc" -eq 0 ] && [ "$(issues -r '.[0].assignees[0].login')" = someone-else ] \ + && issues -r '.[0].body' | grep -q '@someone-else'; then + ok "ALARM_OWNER sets the assignee and the mention" +else + fail "ALARM_OWNER: rc=$rc issues=$(cat "$FAKE_GH/issues.json")" +fi + +# 10. a key with a space or an empty title is refused (exit 2), nothing filed. +fresh bad +alarm open "bad key" "t" "$(body b)"; rc1=$? +alarm open good "" "$(body b)"; rc2=$? +if [ "$rc1" -eq 2 ] && [ "$rc2" -eq 2 ] && [ ! -s "$FAKE_GH/issues.json" ]; then + ok "a bad key or an empty title is refused" +else + fail "bad input: rc=$rc1/$rc2" +fi + +# 11. issues disabled: open prints a ::error annotation and the summary, exits 3. +fresh disabled +printf '^(issue|label) \t-\t1\tthe %s repository has disabled issues\n' "'88plug/k3d-gpu'" > "$FAKE_GH/routes.tsv" +GITHUB_STEP_SUMMARY="$FAKE_GH/summary" alarm open simulate "simulated red" "$(body "the simulated body")"; rc=$? +if [ "$rc" -eq 3 ] && grep -q '^::error title=ci-alarm simulate::' "$FAKE_GH/out" \ + && grep -q 'the simulated body' "$FAKE_GH/summary" && grep -qi 'disabled issues' "$FAKE_GH/err"; then + ok "issues disabled: ::error annotation, step summary, exit 3" +else + fail "issues disabled: rc=$rc out=$(cat "$FAKE_GH/out") err=$(cat "$FAKE_GH/err")" +fi +alarm list; rc=$? +if [ "$rc" -eq 3 ]; then ok "issues disabled: list exits 3"; else fail "issues disabled: list rc=$rc"; fi + +# 12. any other gh failure is exit 1, with gh's error on stderr. +fresh broken +printf '^issue list\t-\t1\tHTTP 502: Bad Gateway\n' > "$FAKE_GH/routes.tsv" +alarm open simulate "simulated red" "$(body b)"; rc=$? +if [ "$rc" -eq 1 ] && grep -q 'HTTP 502' "$FAKE_GH/err"; then + ok "a gh error exits 1 with the error text" +else + fail "gh error: rc=$rc err=$(cat "$FAKE_GH/err")" +fi + +echo "${PASS} passed, ${FAILS} failed" +[ "${FAILS}" -eq 0 ] diff --git a/tests/ci_wiring_test.sh b/tests/ci_wiring_test.sh index 6426f9b7..eb7ef56e 100755 --- a/tests/ci_wiring_test.sh +++ b/tests/ci_wiring_test.sh @@ -77,34 +77,72 @@ if grep -qF 'git status --porcelain -- .github/workflows/' "${BP}"; then else fail "repin-images has no 'git status --porcelain -- .github/workflows/' check" fi -# create-pull-request resets an existing digest branch onto main and force-pushes -# it; GitHub refuses that GITHUB_TOKEN push when the branch's workflow files differ -# from main's, though the digest commit touches none. A step before the action, -# under the same if:, deletes such a branch so the action cuts a fresh one off main. -# Its script then runs against a local origin over file:// (no network). +# Every create-pull-request step (a GITHUB_TOKEN PR) sits between two steps under +# its if:. Before it, hack/autobump-branch-reset deletes the branch when its +# workflow files differ from main's: create-pull-request resets the branch onto +# main and force-pushes it, and GitHub refuses that GITHUB_TOKEN push (no +# `workflows` permission) though the bump commit touches no workflow. After it, +# hack/bot-ci-dispatch dispatches ci.yml on the branch: the PR's own pull_request +# runs wait for approval, a workflow_dispatch run does not. The job grants +# actions: write for the dispatch. Each reset step's script then runs against a +# local origin over file:// (no network), for every such branch. T=$(mktemp -d "${TMPDIR:-/tmp}/ci_wiring.XXXXXX") trap 'rm -rf "$T"' EXIT -if python3 - "${BP}" "${T}/drop.sh" <<'PY' -import sys, yaml -steps = yaml.safe_load(open(sys.argv[1]))["jobs"]["repin-images"]["steps"] -cpr = next(i for i, s in enumerate(steps) - if str(s.get("uses", "")).startswith("peter-evans/create-pull-request@")) -branch = steps[cpr]["with"]["branch"] -drop = [s for s in steps[:cpr] if f"git push origin --delete {branch}" in s.get("run", "")] -if len(drop) != 1 or drop[0].get("if") != steps[cpr].get("if"): +if python3 - "${ROOT}" "${T}" > "${T}/cases" 2> "${T}/cpr.err" <<'PY' +import glob, os, sys, yaml +root, out = sys.argv[1], sys.argv[2] +errs, n = [], 0 +for path in sorted(glob.glob(os.path.join(root, ".github/workflows/*.yml"))): + jobs = (yaml.safe_load(open(path)) or {}).get("jobs") or {} + for jid, job in jobs.items(): + steps = job.get("steps") or [] + for i, s in enumerate(steps): + if not str(s.get("uses", "")).startswith("peter-evans/create-pull-request@"): + continue + n += 1 + where = f"{os.path.basename(path)} {jid}" + cond = s.get("if") + reset = [p for p in steps[:i] if "hack/autobump-branch-reset" in p.get("run", "") and p.get("if") == cond] + disp = [p for p in steps[i + 1:] if "hack/bot-ci-dispatch --branch" in p.get("run", "") and p.get("if") == cond] + if len(reset) != 1: + errs.append(f"{where}: no single step before create-pull-request, under its if:, that runs hack/autobump-branch-reset") + if len(disp) != 1 or "GH_TOKEN" not in (disp[0].get("env") or {}): + errs.append(f"{where}: no single step after create-pull-request, under its if:, that runs hack/bot-ci-dispatch --branch with GH_TOKEN") + if (job.get("permissions") or {}).get("actions") != "write": + errs.append(f"{where}: the job lacks permissions actions: write (bot-ci-dispatch dispatches)") + if len(reset) != 1: + continue + # A matrix branch (autobump/${{ matrix.component }}) runs as component "comp". + sub = lambda v: str(v).replace("${{ matrix.component }}", "comp") + branch = sub(s["with"]["branch"]) + env = {k: sub(v) for k, v in {**(job.get("env") or {}), **(reset[0].get("env") or {})}.items()} + script = sub(reset[0]["run"]) + if "${{" in branch + script + "".join(env.values()): + errs.append(f"{where}: the reset step uses an expression this test cannot evaluate") + continue + d = os.path.join(out, f"case{n}") + os.makedirs(d) + open(os.path.join(d, "run.sh"), "w").write(script) + open(os.path.join(d, "env"), "w").write("".join(f"export {k}='{v}'\n" for k, v in env.items())) + print(f"{where}\t{branch}\t{d}") +if errs or n == 0: + sys.stderr.write("\n".join(errs or ["no create-pull-request step found"]) + "\n") sys.exit(1) -open(sys.argv[2], "w").write(drop[0]["run"]) PY then - ok "repin-images deletes a stale digest branch before create-pull-request, under its if:" + ok "every create-pull-request step: autobump-branch-reset before, bot-ci-dispatch after, under its if:, actions: write" else - fail "repin-images has no step before create-pull-request, under its if:, that runs 'git push origin --delete autobump/image-digests'" + fail "create-pull-request wiring: $(tr '\n' ';' < "${T}/cpr.err")" fi -# $1: absent (no digest branch), same (main moved, no workflow change), differ -# (main changed a workflow after the branch was cut). Prints the branch's state -# on origin after the step's script ran in a depth-1 checkout of main. +for b in autobump/image-digests autobump/convert-pins autobump/comp; do + if cut -f2 "${T}/cases" | grep -qxF "$b"; then ok "a reset step covers ${b}"; else fail "no reset step covers ${b}"; fi +done +# $1: absent (no branch), same (main moved, no workflow change), differ (main +# changed a workflow after the branch was cut); $2 the branch; $3 the case dir. +# Prints the branch's state on origin after the step's script ran in a depth-1 +# checkout of main that carries hack/autobump-branch-reset. drop_case() { - local d="${T}/$1" b=autobump/image-digests + local d="$3/$1" b=$2 ( set -e export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 @@ -112,16 +150,17 @@ drop_case() { git init -q --bare -b main "$d/origin.git" git init -q -b main "$d/seed" cd "$d/seed" - mkdir -p .github/workflows + mkdir -p .github/workflows hack echo v1 > .github/workflows/ci.yml echo 1 > images.lock - git add .github/workflows/ci.yml images.lock + cp "${ROOT}/hack/autobump-branch-reset" hack/ + git add .github/workflows/ci.yml images.lock hack/autobump-branch-reset git commit -qm base git push -q "$d/origin.git" main if [ "$1" != absent ]; then git switch -qc "$b" echo 2 > images.lock - git commit -qam digests + git commit -qam bump git push -q "$d/origin.git" "$b" git switch -q main fi @@ -135,17 +174,27 @@ drop_case() { git push -q "$d/origin.git" main git clone -q --depth=1 --branch main "file://$d/origin.git" "$d/work" cd "$d/work" - bash --noprofile --norc -eo pipefail "${T}/drop.sh" >"$d/log" 2>&1 + # shellcheck disable=SC1091 + . "$3/env" + bash --noprofile --norc -eo pipefail "$3/run.sh" >"$d/log" 2>&1 ) || { echo "script-failed"; return; } if [ -n "$(git ls-remote --heads "$d/origin.git" "refs/heads/$b")" ]; then echo present; else echo gone; fi } -for c in absent:gone same:present differ:gone; do - got=$(drop_case "${c%%:*}") - if [ "${got}" = "${c#*:}" ]; then - ok "stale-branch step, ${c%%:*}: digest branch ${got}" - else - fail "stale-branch step, ${c%%:*}: digest branch ${got}, want ${c#*:}" - fi +while IFS=$'\t' read -r where b dir; do + for c in absent:gone same:present differ:gone; do + got=$(drop_case "${c%%:*}" "$b" "$dir") + if [ "${got}" = "${c#*:}" ]; then + ok "${where} reset step, ${c%%:*}: ${b} ${got}" + else + fail "${where} reset step, ${c%%:*}: ${b} ${got}, want ${c#*:}" + fi + done +done < "${T}/cases" +# autobump-branch-reset deletes only autobump/ branches (usage error, exit 2). +for b in main claude/x autobump/ ''; do + (cd "$T" && bash "${ROOT}/hack/autobump-branch-reset" "$b" >/dev/null 2>&1); rc=$? + if [ "$rc" -eq 2 ]; then ok "autobump-branch-reset refuses '${b}'" + else fail "autobump-branch-reset '${b}': exit ${rc}, want 2"; fi done # The release job builds the 24.04 variant from the Dockerfile's pin, which the # nightly moves; a literal pin in the workflow would never move. @@ -156,5 +205,196 @@ else fail "release does not read UBUNTU_2404_IMAGE from the Dockerfile for the 24.04 variant" fi +# Alarm, watchdog and release hardening: tests/ops/workflow_checks.py reads every +# workflow with PyYAML (a second parser: hack/watchdog reads crons with its own +# regex) and prints one "ok <what>" or "FAIL <what>" line per check. +while IFS= read -r line; do + case "${line}" in + "ok "*) ok "${line#ok }" ;; + *) fail "${line#FAIL }" ;; + esac +done < <(python3 "${ROOT}/tests/ops/workflow_checks.py" "${ROOT}" 2>&1 || echo "FAIL tests/ops/workflow_checks.py exited non-zero") +# ─── CI on bot PRs and alarm surfacing ─────────────────────────────────────── +WF="${ROOT}/.github/workflows" +# ci.yml runs on workflow_dispatch: hack/bot-ci-dispatch runs it on bot PR branches. +# (PyYAML reads the bare key `on` as True.) +if python3 - "${WF}/ci.yml" <<'PY' +import sys, yaml +wf = yaml.safe_load(open(sys.argv[1])) +on = wf.get("on", wf.get(True)) +sys.exit(0 if isinstance(on, dict) and "workflow_dispatch" in on else 1) +PY +then ok "ci.yml has a workflow_dispatch trigger"; else fail "ci.yml has no workflow_dispatch trigger"; fi +# The dependabot auto-merge workflow ran `gh pr merge --auto` on an unprotected main +# (no required checks, so nothing ever merged) and held a run on every bot PR. +if [ ! -e "${WF}/dependabot-auto-merge.yml" ]; then ok "dependabot-auto-merge.yml is gone" +else fail "dependabot-auto-merge.yml still exists"; fi +# bot-sweep.yml: hourly off the hour, never two at once, job ci-fill runs bot-ci-dispatch. +if python3 - "${WF}/bot-sweep.yml" <<'PY' +import sys, yaml +wf = yaml.safe_load(open(sys.argv[1])) +on = wf.get("on", wf.get(True)) or {} +crons = [c["cron"] for c in on.get("schedule") or []] +conc = wf.get("concurrency") or {} +job = (wf.get("jobs") or {}).get("ci-fill") or {} +perms = job.get("permissions") or {} +runs = " ".join(s.get("run", "") for s in job.get("steps") or []) +checks = [ + wf.get("name") == "Bot sweep", + crons == ["13 * * * *"], "workflow_dispatch" in on, + conc.get("group") == "bot-sweep", conc.get("cancel-in-progress") is False, + isinstance(job.get("timeout-minutes"), int), + perms == {"actions": "write", "contents": "read", "pull-requests": "read"}, + "hack/bot-ci-dispatch" in runs, + all("timeout-minutes" in j and "permissions" in j for j in wf["jobs"].values()), +] +sys.exit(0 if all(checks) else 1) +PY +then ok "bot-sweep.yml: 'Bot sweep', cron 13 * * * *, group bot-sweep, job ci-fill runs hack/bot-ci-dispatch" +else fail "bot-sweep.yml is missing or its name, schedule, concurrency or ci-fill job is wrong"; fi + +# ci.yml's last job, alarms: never fails, reads issues and actions only, and warns +# on every open ci-alarm issue and on a missing or stale Watchdog. Its script runs +# against a fake gh. +if python3 - "${WF}/ci.yml" "${T}/alarms.sh" <<'PY' +import sys, yaml +jobs = yaml.safe_load(open(sys.argv[1]))["jobs"] +job = jobs.get("alarms") or {} +ok = (list(jobs)[-1] == "alarms" and job.get("continue-on-error") is True + and isinstance(job.get("timeout-minutes"), int) + and job.get("permissions") == {"issues": "read", "actions": "read"} + and len(job.get("steps") or []) == 1) +if ok: + open(sys.argv[2], "w").write(job["steps"][0]["run"]) +sys.exit(0 if ok else 1) +PY +then ok "ci.yml ends with job alarms: continue-on-error, timeout, issues+actions read" +else fail "ci.yml's last job is not alarms with continue-on-error, timeout-minutes, issues/actions read and one step"; fi +mkdir -p "${T}/abin" +cat > "${T}/abin/gh" <<'STUB' +#!/usr/bin/env bash +A=$AFIX +case "$1 $2" in + "issue list") f=issues;; "workflow list") f=workflows;; "run list") f=runs;; *) exit 0;; +esac +[ -f "$A/$f.fail" ] && { echo "HTTP 502" >&2; exit 1; } +q=; while [ $# -gt 0 ]; do case $1 in -q|--jq) q=$2; shift 2;; *) shift;; esac; done +if [ -n "$q" ]; then jq -r "$q" "$A/$f.json"; else cat "$A/$f.json"; fi +STUB +chmod +x "${T}/abin/gh" +ALARM7='{"number":7,"title":"[ci-alarm] nightly: Auto-update red","url":"https://github.com/o/r/issues/7","labels":[{"name":"ci-alarm"}]}' +ALARM8='{"number":8,"title":"[ci-alarm] release: 100% missing","url":"https://github.com/o/r/issues/8","labels":[{"name":"ci-alarm"},{"name":"alarm-ack"}]}' +WATCHDOG='[{"name":"Watchdog","path":".github/workflows/watchdog.yml","state":"active"}]' +ago() { date -u -d "-$1 hours" +%Y-%m-%dT%H:%M:%SZ; } +# alarm_case NAME ISSUES WORKFLOWS RUNS [fail...]: runs the alarms script; sets AOUT, ARC. +alarm_case() { + AFIX="${T}/alarm-$1"; export AFIX; mkdir -p "$AFIX" + echo "$2" > "$AFIX/issues.json"; echo "$3" > "$AFIX/workflows.json"; echo "$4" > "$AFIX/runs.json" + local f; for f in "${@:5}"; do touch "$AFIX/$f.fail"; done + AOUT=$(PATH="${T}/abin:$PATH" bash --noprofile --norc -eo pipefail "${T}/alarms.sh" 2>&1); ARC=$? +} +alarm_case two "[$ALARM7,$ALARM8]" "$WATCHDOG" "[{\"updatedAt\":\"$(ago 1)\"}]" +if [ "$ARC" -eq 0 ] && [ "$(grep -c '^::warning::' <<<"$AOUT")" -eq 2 ] \ + && grep '^::warning::.*#7' <<<"$AOUT" | grep -F 'https://github.com/o/r/issues/7' | grep -qF 'gh issue edit 7 --add-label alarm-ack' \ + && grep '^::warning::.*#8' <<<"$AOUT" | grep -F 'https://github.com/o/r/issues/8' | grep -qF '100%25 missing' \ + && ! grep -q 'Watchdog' <<<"$AOUT"; then + ok "alarms: one warning per open alarm with its URL and ack state; a fresh Watchdog is quiet" +else fail "alarms two: rc=$ARC out=$(tr '\n' ';' <<<"$AOUT")"; fi +alarm_case none '[]' '[]' '[]' +if [ "$ARC" -eq 0 ] && [ "$(grep -c '^::warning::' <<<"$AOUT")" -eq 1 ] && grep -q '^::warning::.*Watchdog' <<<"$AOUT"; then + ok "alarms: a missing Watchdog workflow warns" +else fail "alarms none: rc=$ARC out=$(tr '\n' ';' <<<"$AOUT")"; fi +alarm_case stale '[]' "$WATCHDOG" "[{\"updatedAt\":\"$(ago 5)\"}]" +if [ "$ARC" -eq 0 ] && grep -q '^::warning::.*Watchdog.*3 h' <<<"$AOUT"; then ok "alarms: a Watchdog success over 3 h old warns" +else fail "alarms stale: rc=$ARC out=$(tr '\n' ';' <<<"$AOUT")"; fi +alarm_case never '[]' "$WATCHDOG" '[]' +if [ "$ARC" -eq 0 ] && grep -q '^::warning::.*Watchdog' <<<"$AOUT"; then ok "alarms: a Watchdog with no success warns" +else fail "alarms never: rc=$ARC out=$(tr '\n' ';' <<<"$AOUT")"; fi +alarm_case disabled '[]' '[{"name":"Watchdog","path":".github/workflows/watchdog.yml","state":"disabled_manually"}]' "[{\"updatedAt\":\"$(ago 1)\"}]" +if [ "$ARC" -eq 0 ] && grep -q '^::warning::.*Watchdog.*disabled_manually' <<<"$AOUT"; then ok "alarms: a disabled Watchdog warns" +else fail "alarms disabled: rc=$ARC out=$(tr '\n' ';' <<<"$AOUT")"; fi +alarm_case gh_down '[]' '[]' '[]' issues workflows runs +if [ "$ARC" -eq 0 ] && [ "$(grep -c '^::warning::' <<<"$AOUT")" -eq 2 ]; then ok "alarms: gh failing warns and still exits 0" +else fail "alarms gh_down: rc=$ARC out=$(tr '\n' ';' <<<"$AOUT")"; fi + +# No version literal in a workflow: every tool pin lives in hack/ci-tools.env (or +# another file outside .github/workflows/), where a token without the `workflows` +# permission can move it. Outside `uses:` lines (action SHAs) and comments, a +# workflow names no version, ==version, checksum or digest, except the numbers below, +# which name no pin. +if out=$(python3 - "${ROOT}" 2>&1 <<'PY' +import glob, re, sys +root = sys.argv[1] +ALLOWED = [ + (r"(?i)ubuntu[ :]?2[46]\\?\.04|\*\*2[46]\.04\*\*", "the node image's Ubuntu variants this repo publishes"), + (r"kubeconform 1\.34/1\.35", "the Kubernetes versions tests/kserve_test.sh validates, in a job name"), + (r"e\.g\. 0\.1\.[0-9]+", "an example value in an input description"), +] +VERSION = re.compile(r"(?<![\w.-])v?[0-9]+\.[0-9]+(?:\.[0-9]+)*(?![\w.])|==\s*v?[0-9]|" + r"(?<![0-9a-f])[0-9a-f]{40}(?![0-9a-f])|(?<![0-9a-f])[0-9a-f]{64}(?![0-9a-f])") +bad = [] +for path in sorted(glob.glob(root + "/.github/workflows/*.yml")): + for n, line in enumerate(open(path), 1): + s = re.sub(r"(^|\s)#.*$", "", line.rstrip("\n")) + if re.match(r"^\s*(-\s+)?uses:", s): + continue + for a, _ in ALLOWED: + s = re.sub(a, "", s) + for m in VERSION.finditer(s): + bad.append("%s:%d: %s" % (path[len(root) + 1:], n, m.group(0))) +print("\n".join(bad)) +sys.exit(1 if bad else 0) +PY +); then + ok "no version literal in .github/workflows/ outside uses: lines (tool pins live in hack/ci-tools.env)" +else + fail "version literals in workflows; move them to hack/ci-tools.env"; sed 's/^/ | /' <<< "${out}" +fi + +# A job that reads a hack/ci-tools.env key loads the file into GITHUB_ENV in a step +# before the first step that uses it, and nothing else defines the key; every key +# is used; the kubeconform test scripts read KUBECONFORM_IMAGE from the file. +if out=$(python3 - "${ROOT}" 2>&1 <<'PY' +import glob, re, sys, yaml +root = sys.argv[1] +keys = [l.split("=", 1)[0] for l in open(root + "/hack/ci-tools.env") if re.match(r"^[A-Z0-9_]+=", l)] +LOAD = re.compile(r"""grep -E '\^\[A-Z0-9_\]\+=' hack/ci-tools\.env >> "\$GITHUB_ENV\"""") +bad, used = [], set() +def uses(text, key): + return re.search(r"\$\{\{\s*env\.%s\s*\}\}|\$\{?%s\b" % (key, key), text) is not None +for path in sorted(glob.glob(root + "/.github/workflows/*.yml")): + doc = yaml.safe_load(open(path)) + rel = path[len(root) + 1:] + for key in keys: + if key in (doc.get("env") or {}): + bad.append("%s: workflow env defines %s; load it from hack/ci-tools.env" % (rel, key)) + for job, spec in (doc.get("jobs") or {}).items(): + steps = spec.get("steps") or [] + loaded = next((i for i, st in enumerate(steps) if LOAD.search(st.get("run") or "")), None) + for key in keys: + if key in (spec.get("env") or {}): + bad.append("%s %s: job env defines %s; load it from hack/ci-tools.env" % (rel, job, key)) + first = next((i for i, st in enumerate(steps) if uses(yaml.safe_dump(st), key)), None) + if first is None: + continue + used.add(key) + if loaded is None or loaded >= first: + bad.append("%s %s: step %d uses %s before a step loads hack/ci-tools.env" % (rel, job, first, key)) +for t in ("kubeconform.sh", "agent_kubeconform.sh", "share_kubeconform.sh"): + text = open(root + "/tests/" + t).read() + code = re.sub(r"(?m)^\s*#.*$", "", text) + if "hack/ci-tools.env" not in code or re.search(r"KUBECONFORM_IMAGE=[\"']?[a-z]", code): + bad.append("tests/%s: read KUBECONFORM_IMAGE from hack/ci-tools.env, with no literal of its own" % t) + used.add("KUBECONFORM_IMAGE") +bad += ["hack/ci-tools.env: %s is used by no workflow" % k for k in keys if k not in used] +print("\n".join(bad)) +sys.exit(1 if bad else 0) +PY +); then + ok "jobs load hack/ci-tools.env before using its keys; every key is used; kubeconform scripts read it" +else + fail "hack/ci-tools.env wiring"; sed 's/^/ | /' <<< "${out}" +fi + echo "${PASS} passed, ${FAILS} failed" [ "${FAILS}" -eq 0 ] diff --git a/tests/console/audit/chat-ui-noauth.golden b/tests/console/audit/chat-ui-noauth.golden index bb79565d..c7400842 100644 --- a/tests/console/audit/chat-ui-noauth.golden +++ b/tests/console/audit/chat-ui-noauth.golden @@ -1,7 +1,7 @@ -audit FAIL: 1 of 12 checks failed (images.lock: 55 images, k3s v1.37.1-k3s1) +audit FAIL: 1 of 12 checks failed (images.lock: 54 images, k3s v1.37.1-k3s1) ok listener 0.0.0.0:39651 kube-apiserver: off loopback, auth client-cert (docker port 6443/tcp on k3d-gpu-server-0) ok image docker.io/intel/intel-gpu-plugin:0.37.1: sha256:004641b2547d in images.lock (share/intel-gpu-plugin.yml, share/intel-gpu-wsl-plugin.yml); on k3d-gpu-server-0 -ok image docker.io/openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58: sha256:a928903fe429 in images.lock (scripts/k3d-gpu AGENT_OVMS_IMAGE); on k3d-gpu-server-0 +ok image docker.io/openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58: sha256:a928903fe429 in images.lock (scripts/k3d-gpu AGENT_OVMS_IMAGE, scripts/k3d-gpu NPU_UMD_IMAGE, share/npu-embeddings.yml); on k3d-gpu-server-0 ok image docker.io/rancher/local-path-provisioner:v0.0.37: sha256:e757967a5ec3 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 ok image docker.io/rancher/mirrored-coredns-coredns:1.14.7: sha256:7efd3c635b03 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 ok image docker.io/rancher/mirrored-metrics-server:v0.9.0: sha256:d9862115e7c7 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 diff --git a/tests/console/audit/chat-ui.golden b/tests/console/audit/chat-ui.golden index 0e4baf57..b49dbceb 100644 --- a/tests/console/audit/chat-ui.golden +++ b/tests/console/audit/chat-ui.golden @@ -1,7 +1,7 @@ -audit PASS: 0 of 12 checks failed (images.lock: 55 images, k3s v1.37.1-k3s1) +audit PASS: 0 of 12 checks failed (images.lock: 54 images, k3s v1.37.1-k3s1) ok listener 0.0.0.0:39651 kube-apiserver: off loopback, auth client-cert (docker port 6443/tcp on k3d-gpu-server-0) ok image docker.io/intel/intel-gpu-plugin:0.37.1: sha256:004641b2547d in images.lock (share/intel-gpu-plugin.yml, share/intel-gpu-wsl-plugin.yml); on k3d-gpu-server-0 -ok image docker.io/openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58: sha256:a928903fe429 in images.lock (scripts/k3d-gpu AGENT_OVMS_IMAGE); on k3d-gpu-server-0 +ok image docker.io/openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58: sha256:a928903fe429 in images.lock (scripts/k3d-gpu AGENT_OVMS_IMAGE, scripts/k3d-gpu NPU_UMD_IMAGE, share/npu-embeddings.yml); on k3d-gpu-server-0 ok image docker.io/rancher/local-path-provisioner:v0.0.37: sha256:e757967a5ec3 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 ok image docker.io/rancher/mirrored-coredns-coredns:1.14.7: sha256:7efd3c635b03 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 ok image docker.io/rancher/mirrored-metrics-server:v0.9.0: sha256:d9862115e7c7 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 diff --git a/tests/console/audit/default-json.golden b/tests/console/audit/default-json.golden index 50d812bf..d134bfe5 100644 --- a/tests/console/audit/default-json.golden +++ b/tests/console/audit/default-json.golden @@ -15,7 +15,7 @@ "subject": "docker.io/intel/intel-gpu-plugin:0.37.1" }, { - "detail": "sha256:a928903fe429 in images.lock (scripts/k3d-gpu AGENT_OVMS_IMAGE); on k3d-gpu-server-0", + "detail": "sha256:a928903fe429 in images.lock (scripts/k3d-gpu AGENT_OVMS_IMAGE, scripts/k3d-gpu NPU_UMD_IMAGE, share/npu-embeddings.yml); on k3d-gpu-server-0", "fix": null, "rule": "image", "status": "pass", @@ -73,12 +73,12 @@ ], "cluster": "gpu", "lock": { - "images": 55, + "images": 54, "k3s_tag": "v1.37.1-k3s1" }, "schema": "k3d-gpu.audit/v1", "summary": [ - "audit PASS: 0 of 10 checks failed (images.lock: 55 images, k3s v1.37.1-k3s1)" + "audit PASS: 0 of 10 checks failed (images.lock: 54 images, k3s v1.37.1-k3s1)" ], "verdict": "pass" } diff --git a/tests/console/audit/default.golden b/tests/console/audit/default.golden index 9dd398e8..61669446 100644 --- a/tests/console/audit/default.golden +++ b/tests/console/audit/default.golden @@ -1,7 +1,7 @@ -audit PASS: 0 of 10 checks failed (images.lock: 55 images, k3s v1.37.1-k3s1) +audit PASS: 0 of 10 checks failed (images.lock: 54 images, k3s v1.37.1-k3s1) ok listener 0.0.0.0:39651 kube-apiserver: off loopback, auth client-cert (docker port 6443/tcp on k3d-gpu-server-0) ok image docker.io/intel/intel-gpu-plugin:0.37.1: sha256:004641b2547d in images.lock (share/intel-gpu-plugin.yml, share/intel-gpu-wsl-plugin.yml); on k3d-gpu-server-0 -ok image docker.io/openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58: sha256:a928903fe429 in images.lock (scripts/k3d-gpu AGENT_OVMS_IMAGE); on k3d-gpu-server-0 +ok image docker.io/openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58: sha256:a928903fe429 in images.lock (scripts/k3d-gpu AGENT_OVMS_IMAGE, scripts/k3d-gpu NPU_UMD_IMAGE, share/npu-embeddings.yml); on k3d-gpu-server-0 ok image docker.io/rancher/local-path-provisioner:v0.0.37: sha256:e757967a5ec3 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 ok image docker.io/rancher/mirrored-coredns-coredns:1.14.7: sha256:7efd3c635b03 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 ok image docker.io/rancher/mirrored-metrics-server:v0.9.0: sha256:d9862115e7c7 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 diff --git a/tests/console/audit/image-digest-moved.golden b/tests/console/audit/image-digest-moved.golden index 60b32c8f..ae7bb42e 100644 --- a/tests/console/audit/image-digest-moved.golden +++ b/tests/console/audit/image-digest-moved.golden @@ -1,8 +1,8 @@ -audit FAIL: 1 of 10 checks failed (images.lock: 55 images, k3s v1.37.1-k3s1) +audit FAIL: 1 of 10 checks failed (images.lock: 54 images, k3s v1.37.1-k3s1) ok listener 0.0.0.0:39651 kube-apiserver: off loopback, auth client-cert (docker port 6443/tcp on k3d-gpu-server-0) FAIL image docker.io/intel/intel-gpu-plugin:0.37.1: docker.io/intel/intel-gpu-plugin is sha256:2222222222222222222222222222222222222222222222222222222222222222, images.lock pins sha256:004641b2547d58f1eadce58473fa160ad71377de1eb588cf1f82832387bf3e33 (tag 0.37.1); on k3d-gpu-server-0 fix: redeploy from the pinned digest, or bump the pin with scripts/images-lock update -ok image docker.io/openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58: sha256:a928903fe429 in images.lock (scripts/k3d-gpu AGENT_OVMS_IMAGE); on k3d-gpu-server-0 +ok image docker.io/openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58: sha256:a928903fe429 in images.lock (scripts/k3d-gpu AGENT_OVMS_IMAGE, scripts/k3d-gpu NPU_UMD_IMAGE, share/npu-embeddings.yml); on k3d-gpu-server-0 ok image docker.io/rancher/local-path-provisioner:v0.0.37: sha256:e757967a5ec3 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 ok image docker.io/rancher/mirrored-coredns-coredns:1.14.7: sha256:7efd3c635b03 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 ok image docker.io/rancher/mirrored-metrics-server:v0.9.0: sha256:d9862115e7c7 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 diff --git a/tests/console/audit/image-no-digest.golden b/tests/console/audit/image-no-digest.golden index 33263aa9..b2430703 100644 --- a/tests/console/audit/image-no-digest.golden +++ b/tests/console/audit/image-no-digest.golden @@ -1,9 +1,9 @@ -audit FAIL: 1 of 11 checks failed (images.lock: 55 images, k3s v1.37.1-k3s1) +audit FAIL: 1 of 11 checks failed (images.lock: 54 images, k3s v1.37.1-k3s1) ok listener 0.0.0.0:39651 kube-apiserver: off loopback, auth client-cert (docker port 6443/tcp on k3d-gpu-server-0) FAIL image docker.io/library/app:dev: no registry digest, so it cannot match images.lock; on k3d-gpu-server-0 fix: remove it, or load the image from a registry reference that images.lock pins ok image docker.io/intel/intel-gpu-plugin:0.37.1: sha256:004641b2547d in images.lock (share/intel-gpu-plugin.yml, share/intel-gpu-wsl-plugin.yml); on k3d-gpu-server-0 -ok image docker.io/openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58: sha256:a928903fe429 in images.lock (scripts/k3d-gpu AGENT_OVMS_IMAGE); on k3d-gpu-server-0 +ok image docker.io/openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58: sha256:a928903fe429 in images.lock (scripts/k3d-gpu AGENT_OVMS_IMAGE, scripts/k3d-gpu NPU_UMD_IMAGE, share/npu-embeddings.yml); on k3d-gpu-server-0 ok image docker.io/rancher/local-path-provisioner:v0.0.37: sha256:e757967a5ec3 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 ok image docker.io/rancher/mirrored-coredns-coredns:1.14.7: sha256:7efd3c635b03 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 ok image docker.io/rancher/mirrored-metrics-server:v0.9.0: sha256:d9862115e7c7 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 diff --git a/tests/console/audit/image-not-in-lock.golden b/tests/console/audit/image-not-in-lock.golden index e529e48c..c0d9d3a6 100644 --- a/tests/console/audit/image-not-in-lock.golden +++ b/tests/console/audit/image-not-in-lock.golden @@ -1,9 +1,9 @@ -audit FAIL: 1 of 11 checks failed (images.lock: 55 images, k3s v1.37.1-k3s1) +audit FAIL: 1 of 11 checks failed (images.lock: 54 images, k3s v1.37.1-k3s1) ok listener 0.0.0.0:39651 kube-apiserver: off loopback, auth client-cert (docker port 6443/tcp on k3d-gpu-server-0) FAIL image docker.io/bitnami/redis:7.4: not in images.lock: docker.io/bitnami/redis; on k3d-gpu-server-0 fix: remove the workload that runs it, or pin it in the repo and run scripts/images-lock update ok image docker.io/intel/intel-gpu-plugin:0.37.1: sha256:004641b2547d in images.lock (share/intel-gpu-plugin.yml, share/intel-gpu-wsl-plugin.yml); on k3d-gpu-server-0 -ok image docker.io/openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58: sha256:a928903fe429 in images.lock (scripts/k3d-gpu AGENT_OVMS_IMAGE); on k3d-gpu-server-0 +ok image docker.io/openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58: sha256:a928903fe429 in images.lock (scripts/k3d-gpu AGENT_OVMS_IMAGE, scripts/k3d-gpu NPU_UMD_IMAGE, share/npu-embeddings.yml); on k3d-gpu-server-0 ok image docker.io/rancher/local-path-provisioner:v0.0.37: sha256:e757967a5ec3 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 ok image docker.io/rancher/mirrored-coredns-coredns:1.14.7: sha256:7efd3c635b03 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 ok image docker.io/rancher/mirrored-metrics-server:v0.9.0: sha256:d9862115e7c7 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 diff --git a/tests/console/audit/ipv6-unauth.golden b/tests/console/audit/ipv6-unauth.golden index 0f8f1f4c..3710cf04 100644 --- a/tests/console/audit/ipv6-unauth.golden +++ b/tests/console/audit/ipv6-unauth.golden @@ -1,9 +1,9 @@ -audit FAIL: 1 of 11 checks failed (images.lock: 55 images, k3s v1.37.1-k3s1) +audit FAIL: 1 of 11 checks failed (images.lock: 54 images, k3s v1.37.1-k3s1) FAIL listener [::]:8000 vllm (EAT chart, no auth): unauthenticated listener off loopback: no authentication; off loopback, auth none (fixture: EAT#9 layout) fix: bind it to 127.0.0.1, or put OIDC in front of it (k3d-gpu plan --bind picks oidc off loopback) ok listener 0.0.0.0:39651 kube-apiserver: off loopback, auth client-cert (docker port 6443/tcp on k3d-gpu-server-0) ok image docker.io/intel/intel-gpu-plugin:0.37.1: sha256:004641b2547d in images.lock (share/intel-gpu-plugin.yml, share/intel-gpu-wsl-plugin.yml); on k3d-gpu-server-0 -ok image docker.io/openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58: sha256:a928903fe429 in images.lock (scripts/k3d-gpu AGENT_OVMS_IMAGE); on k3d-gpu-server-0 +ok image docker.io/openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58: sha256:a928903fe429 in images.lock (scripts/k3d-gpu AGENT_OVMS_IMAGE, scripts/k3d-gpu NPU_UMD_IMAGE, share/npu-embeddings.yml); on k3d-gpu-server-0 ok image docker.io/rancher/local-path-provisioner:v0.0.37: sha256:e757967a5ec3 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 ok image docker.io/rancher/mirrored-coredns-coredns:1.14.7: sha256:7efd3c635b03 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 ok image docker.io/rancher/mirrored-metrics-server:v0.9.0: sha256:d9862115e7c7 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 diff --git a/tests/console/audit/kubeconfig-0644.golden b/tests/console/audit/kubeconfig-0644.golden index d02bffb1..2921e599 100644 --- a/tests/console/audit/kubeconfig-0644.golden +++ b/tests/console/audit/kubeconfig-0644.golden @@ -1,7 +1,7 @@ -audit FAIL: 1 of 10 checks failed (images.lock: 55 images, k3s v1.37.1-k3s1) +audit FAIL: 1 of 10 checks failed (images.lock: 54 images, k3s v1.37.1-k3s1) ok listener 0.0.0.0:39651 kube-apiserver: off loopback, auth client-cert (docker port 6443/tcp on k3d-gpu-server-0) ok image docker.io/intel/intel-gpu-plugin:0.37.1: sha256:004641b2547d in images.lock (share/intel-gpu-plugin.yml, share/intel-gpu-wsl-plugin.yml); on k3d-gpu-server-0 -ok image docker.io/openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58: sha256:a928903fe429 in images.lock (scripts/k3d-gpu AGENT_OVMS_IMAGE); on k3d-gpu-server-0 +ok image docker.io/openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58: sha256:a928903fe429 in images.lock (scripts/k3d-gpu AGENT_OVMS_IMAGE, scripts/k3d-gpu NPU_UMD_IMAGE, share/npu-embeddings.yml); on k3d-gpu-server-0 ok image docker.io/rancher/local-path-provisioner:v0.0.37: sha256:e757967a5ec3 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 ok image docker.io/rancher/mirrored-coredns-coredns:1.14.7: sha256:7efd3c635b03 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 ok image docker.io/rancher/mirrored-metrics-server:v0.9.0: sha256:d9862115e7c7 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 diff --git a/tests/console/audit/loopback-published.golden b/tests/console/audit/loopback-published.golden index fee6dd21..c2c87a52 100644 --- a/tests/console/audit/loopback-published.golden +++ b/tests/console/audit/loopback-published.golden @@ -1,8 +1,8 @@ -audit PASS: 0 of 11 checks failed (images.lock: 55 images, k3s v1.37.1-k3s1) +audit PASS: 0 of 11 checks failed (images.lock: 54 images, k3s v1.37.1-k3s1) ok listener 0.0.0.0:39651 kube-apiserver: off loopback, auth client-cert (docker port 6443/tcp on k3d-gpu-server-0) ok listener 127.0.0.1:8000 published node port 30080/tcp: loopback, auth unknown (docker port 30080/tcp on k3d-gpu-server-0) ok image docker.io/intel/intel-gpu-plugin:0.37.1: sha256:004641b2547d in images.lock (share/intel-gpu-plugin.yml, share/intel-gpu-wsl-plugin.yml); on k3d-gpu-server-0 -ok image docker.io/openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58: sha256:a928903fe429 in images.lock (scripts/k3d-gpu AGENT_OVMS_IMAGE); on k3d-gpu-server-0 +ok image docker.io/openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58: sha256:a928903fe429 in images.lock (scripts/k3d-gpu AGENT_OVMS_IMAGE, scripts/k3d-gpu NPU_UMD_IMAGE, share/npu-embeddings.yml); on k3d-gpu-server-0 ok image docker.io/rancher/local-path-provisioner:v0.0.37: sha256:e757967a5ec3 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 ok image docker.io/rancher/mirrored-coredns-coredns:1.14.7: sha256:7efd3c635b03 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 ok image docker.io/rancher/mirrored-metrics-server:v0.9.0: sha256:d9862115e7c7 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 diff --git a/tests/console/audit/runtime-fetch.golden b/tests/console/audit/runtime-fetch.golden index 49d6db75..c6734f5a 100644 --- a/tests/console/audit/runtime-fetch.golden +++ b/tests/console/audit/runtime-fetch.golden @@ -1,7 +1,7 @@ -audit FAIL: 1 of 11 checks failed (images.lock: 55 images, k3s v1.37.1-k3s1) +audit FAIL: 1 of 11 checks failed (images.lock: 54 images, k3s v1.37.1-k3s1) ok listener 0.0.0.0:39651 kube-apiserver: off loopback, auth client-cert (docker port 6443/tcp on k3d-gpu-server-0) ok image docker.io/intel/intel-gpu-plugin:0.37.1: sha256:004641b2547d in images.lock (share/intel-gpu-plugin.yml, share/intel-gpu-wsl-plugin.yml); on k3d-gpu-server-0 -ok image docker.io/openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58: sha256:a928903fe429 in images.lock (scripts/k3d-gpu AGENT_OVMS_IMAGE); on k3d-gpu-server-0 +ok image docker.io/openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58: sha256:a928903fe429 in images.lock (scripts/k3d-gpu AGENT_OVMS_IMAGE, scripts/k3d-gpu NPU_UMD_IMAGE, share/npu-embeddings.yml); on k3d-gpu-server-0 ok image docker.io/rancher/local-path-provisioner:v0.0.37: sha256:e757967a5ec3 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 ok image docker.io/rancher/mirrored-coredns-coredns:1.14.7: sha256:7efd3c635b03 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 ok image docker.io/rancher/mirrored-metrics-server:v0.9.0: sha256:d9862115e7c7 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 diff --git a/tests/console/audit/runtime-pull.golden b/tests/console/audit/runtime-pull.golden index e96d3d8a..3de318b3 100644 --- a/tests/console/audit/runtime-pull.golden +++ b/tests/console/audit/runtime-pull.golden @@ -1,7 +1,7 @@ -audit FAIL: 2 of 12 checks failed (images.lock: 55 images, k3s v1.37.1-k3s1) +audit FAIL: 2 of 12 checks failed (images.lock: 54 images, k3s v1.37.1-k3s1) ok listener 0.0.0.0:39651 kube-apiserver: off loopback, auth client-cert (docker port 6443/tcp on k3d-gpu-server-0) ok image docker.io/intel/intel-gpu-plugin:0.37.1: sha256:004641b2547d in images.lock (share/intel-gpu-plugin.yml, share/intel-gpu-wsl-plugin.yml); on k3d-gpu-server-0 -ok image docker.io/openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58: sha256:a928903fe429 in images.lock (scripts/k3d-gpu AGENT_OVMS_IMAGE); on k3d-gpu-server-0 +ok image docker.io/openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58: sha256:a928903fe429 in images.lock (scripts/k3d-gpu AGENT_OVMS_IMAGE, scripts/k3d-gpu NPU_UMD_IMAGE, share/npu-embeddings.yml); on k3d-gpu-server-0 ok image docker.io/rancher/local-path-provisioner:v0.0.37: sha256:e757967a5ec3 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 ok image docker.io/rancher/mirrored-coredns-coredns:1.14.7: sha256:7efd3c635b03 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 ok image docker.io/rancher/mirrored-metrics-server:v0.9.0: sha256:d9862115e7c7 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 diff --git a/tests/console/audit/unauth-published.golden b/tests/console/audit/unauth-published.golden index 57c09f13..9a68e42b 100644 --- a/tests/console/audit/unauth-published.golden +++ b/tests/console/audit/unauth-published.golden @@ -1,9 +1,9 @@ -audit FAIL: 1 of 11 checks failed (images.lock: 55 images, k3s v1.37.1-k3s1) +audit FAIL: 1 of 11 checks failed (images.lock: 54 images, k3s v1.37.1-k3s1) FAIL listener 0.0.0.0:8000 published node port 30080/tcp: unauthenticated listener off loopback: no authentication this audit can establish; off loopback, auth unknown (docker port 30080/tcp on k3d-gpu-server-0) fix: bind it to 127.0.0.1, or put OIDC in front of it (k3d-gpu plan --bind picks oidc off loopback) ok listener 0.0.0.0:39651 kube-apiserver: off loopback, auth client-cert (docker port 6443/tcp on k3d-gpu-server-0) ok image docker.io/intel/intel-gpu-plugin:0.37.1: sha256:004641b2547d in images.lock (share/intel-gpu-plugin.yml, share/intel-gpu-wsl-plugin.yml); on k3d-gpu-server-0 -ok image docker.io/openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58: sha256:a928903fe429 in images.lock (scripts/k3d-gpu AGENT_OVMS_IMAGE); on k3d-gpu-server-0 +ok image docker.io/openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58: sha256:a928903fe429 in images.lock (scripts/k3d-gpu AGENT_OVMS_IMAGE, scripts/k3d-gpu NPU_UMD_IMAGE, share/npu-embeddings.yml); on k3d-gpu-server-0 ok image docker.io/rancher/local-path-provisioner:v0.0.37: sha256:e757967a5ec3 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 ok image docker.io/rancher/mirrored-coredns-coredns:1.14.7: sha256:7efd3c635b03 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 ok image docker.io/rancher/mirrored-metrics-server:v0.9.0: sha256:d9862115e7c7 in images.lock (k3s-images.txt v1.37.1-k3s1); on k3d-gpu-server-0 diff --git a/tests/console/green-report.html b/tests/console/green-report.html index 44fee0e8..671c4152 100644 --- a/tests/console/green-report.html +++ b/tests/console/green-report.html @@ -67,9 +67,9 @@ <h2>Detected</h2> <h2>Node labels</h2> <div class="wrap"><table><tbody><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVXVNNI</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.CETIBT</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.CETSS</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.GFNI</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.SHA</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.VAES</th><td>true</td></tr><tr><th>gpu.intel.com/media-vcs</th><td>2</td></tr><tr><th>gpu.intel.com/media-vecs</th><td>1</td></tr><tr><th>gpu.intel.com/xmx</th><td>false</td></tr><tr><th>k3d-gpu.io/hybrid-cpu</th><td>true</td></tr></tbody></table></div> <h2>Release pins</h2> -<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> +<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> <footer> -<p>Rendered from facts/v1, sha256 of its canonical bytes <code>054c57a66f59acbf44280b23d693d17a634b1a9ab433ea016de6812ffd8909da</code>, and plan/v1.</p> +<p>Rendered from facts/v1, sha256 of its canonical bytes <code>8a92631ab5d6f1a56235ddda444ca29625889d0ffc494f0fd8f617c060aba869</code>, and plan/v1.</p> <p>This page runs no script and loads nothing: its Content-Security-Policy is <code>default-src 'none'</code>. The links are references to follow, not resources it fetches.</p> <p><a href="https://github.com/88plug/k3d-gpu" rel="noreferrer">https://github.com/88plug/k3d-gpu</a></p> </footer></main></body></html> diff --git a/tests/console/test_console.py b/tests/console/test_console.py index 65fda26e..3b5aad17 100755 --- a/tests/console/test_console.py +++ b/tests/console/test_console.py @@ -267,6 +267,17 @@ def test_pin_bump_expires_cells(self): self.assertEqual(got["decision"], "unmeasured") self.assertTrue(all("engine pin moved" in " ".join(c["reasons"]) for c in got["considered"])) + def test_tag_beside_the_pinned_digest_keeps_cells(self): + # The release pins name:tag@sha256:X; a cell measured on name@sha256:X ran that image. + name, digest = self.matrix["rows"][0]["engine"]["image"].split("@") + facts = copy.deepcopy(self.facts) + facts["release"]["pins"]["ovms"] = "%s:2026.4.1-gpu@%s" % (name, digest) + self.assertEqual(planmod.plan(facts, self.green())["cell"], "raptorlake-s.cpu-pcores.ovms.qwen3-4b-int4") + facts["release"]["pins"]["ovms"] = "%s:2026.4.1-gpu@sha256:%s" % (name, "0" * 64) + self.assertEqual(planmod.plan(facts, self.green())["decision"], "unmeasured") + self.assertEqual(planmod.image_id("localhost:5000/a/b:1@sha256:" + "1" * 64), "localhost:5000/a/b@sha256:" + "1" * 64) + self.assertEqual(planmod.image_id("localhost:5000/a/b:1"), "localhost:5000/a/b:1") + def test_community_cell_is_amber_and_never_picked(self): m = self.green() for r in m["rows"]: diff --git a/tests/console_test.sh b/tests/console_test.sh index d22f178f..44acc707 100755 --- a/tests/console_test.sh +++ b/tests/console_test.sh @@ -203,7 +203,7 @@ pins() { python3 -c 'import json,sys; p=json.load(sys.stdin)["release"]["pins"]; ovms_pin=$(run facts --json 2>/dev/null | pins) vllm_pin=$(K3D_GPU_AGENT_BACKEND=vllm K3D_GPU_AGENT_IMAGE=example/vllm@sha256:0 run facts --json 2>/dev/null | pins) over_pin=$(K3D_GPU_AGENT_IMAGE=example/ovms@sha256:1 run facts --json 2>/dev/null | pins) -if [[ "${ovms_pin}" == "openvino/model_server@sha256:"*" ghcr.io/ggml-org/llama.cpp@sha256:"* ]] \ +if [[ "${ovms_pin}" == "openvino/model_server:"*"@sha256:"*" ghcr.io/ggml-org/llama.cpp:"*"@sha256:"* ]] \ && [ "${vllm_pin}" = "${ovms_pin}" ] && [ "${over_pin}" = "example/ovms@sha256:1 ${ovms_pin#* }" ]; then ok "facts pins: OVMS and llama.cpp digests; a vLLM backend leaves the OVMS pin alone" else diff --git a/tests/detect_test.sh b/tests/detect_test.sh index 94554468..eaae79e1 100755 --- a/tests/detect_test.sh +++ b/tests/detect_test.sh @@ -2576,7 +2576,7 @@ agent_says "default model gets the qwen3 reasoning parser" "${r}" 0 '--reasoning agent_says "an empty K3D_GPU_AGENT_TOOL_PARSER turns the parser off" "${r}" 0 'tool_parser' negate \ STUB_ALLOC='cpu 8' K3D_GPU_AGENT_TOOL_PARSER= agent_says "the model cache lives on the models volume" "${r}" 0 '--cache_dir=/models/\.ov_cache' "" STUB_ALLOC='cpu 8' -agent_says "the server image is pinned by digest" "${r}" 0 'image: openvino/model_server@sha256:[0-9a-f]{64}' "" STUB_ALLOC='cpu 8' +agent_says "the server image is pinned by digest" "${r}" 0 'image: openvino/model_server:[^@ ]+@sha256:[0-9a-f]{64}' "" STUB_ALLOC='cpu 8' agent_says "server logs at INFO by default" "${r}" 0 '--log_level=INFO$' "" STUB_ALLOC='cpu 8' agent_says "K3D_GPU_AGENT_LOG_LEVEL=DEBUG reaches the server" "${r}" 0 '--log_level=DEBUG$' "" \ STUB_ALLOC='cpu 8' K3D_GPU_AGENT_LOG_LEVEL=DEBUG @@ -3267,11 +3267,11 @@ pci "${r}" 0000:03:00.0 0x8086 0xe20b xe; render "${r}" 0000:03:00.0 128 gid=$(stat -c %g "${r}/dev/dri/renderD128") XE1=$'cpu 16\ngpu.intel.com/xe 1' agent_says "vllm backend serves from the upstream vLLM XPU image, pinned by digest" "${r}" 0 \ - 'image: vllm/vllm-openai-xpu@sha256:[0-9a-f]{64}$' "" STUB_ALLOC="${XE1}" K3D_GPU_AGENT_BACKEND=vllm + 'image: vllm/vllm-openai-xpu:[^@ ]+@sha256:[0-9a-f]{64}$' "" STUB_ALLOC="${XE1}" K3D_GPU_AGENT_BACKEND=vllm agent_says "llm-scaler backend serves from Intel's llm-scaler image, pinned by digest" "${r}" 0 \ - 'image: intel/llm-scaler-vllm@sha256:[0-9a-f]{64}$' "" STUB_ALLOC="${XE1}" K3D_GPU_AGENT_BACKEND=llm-scaler + 'image: intel/llm-scaler-vllm:[^@ ]+@sha256:[0-9a-f]{64}$' "" STUB_ALLOC="${XE1}" K3D_GPU_AGENT_BACKEND=llm-scaler agent_says "the backend name is case-insensitive" "${r}" 0 \ - 'vllm/vllm-openai-xpu@' "" STUB_ALLOC="${XE1}" K3D_GPU_AGENT_BACKEND=vLLM + 'vllm/vllm-openai-xpu:[^@ ]+@' "" STUB_ALLOC="${XE1}" K3D_GPU_AGENT_BACKEND=vLLM agent_says "an unknown K3D_GPU_AGENT_BACKEND is rejected" "${r}" fail \ "unknown K3D_GPU_AGENT_BACKEND 'tgi'" "" STUB_ALLOC="${XE1}" K3D_GPU_AGENT_BACKEND=tgi agent_says "vllm pod requests one gpu.intel.com/xe" "${r}" 0 \ @@ -3356,7 +3356,7 @@ agent_says "vllm on a Battlemage host prints no hardware warning" "${r}" 0 \ agent_says "vllm on a hybrid node sits on P-cores" "${r}" 0 \ 'k3d-gpu.io/cores: performance' "" STUB_ALLOC="${XE1}" K3D_GPU_AGENT_BACKEND=vllm STUB_NODE_LABELS=k3d-gpu.io/hybrid-cpu=true agent_says "the OVMS default still owns the agent when no backend is set" "${r}" 0 \ - 'image: openvino/model_server@' "" STUB_ALLOC="${XE1}" + 'image: openvino/model_server:[^@ ]+@' "" STUB_ALLOC="${XE1}" agent_says "the agent line names the GPU resource it requests" "${r}" 0 \ 'on GPU \(gpu\.intel\.com/xe\)' "" STUB_ALLOC="${XE1}" STUB_NODE_LABELS=gpu.intel.com/xmx=true agent_says "vllm auto runs on the Intel GPU even where OVMS would pick the CPU (no XMX)" "${r}" 0 \ @@ -3455,7 +3455,7 @@ agent_says "llm-scaler auto skips the Gaudi for the Intel GPU" "${r}" 0 \ agent_says "an explicit vllm backend runs on the Gaudi" "${r}" 0 \ '^ +habana\.ai/gaudi: 1$' "" STUB_ALLOC="${HPU_ALLOC}" K3D_GPU_AGENT_BACKEND=vllm agent_says "the vLLM image is Intel's vllm-gaudi 0.30.0 build pinned by digest" "${r}" 0 \ - 'image: vault\.habana\.ai/gaudi-docker/1\.24\.2/ubuntu24\.04/habanalabs/vllm-0\.30\.0-ptupstream-2\.13\.0@sha256:[0-9a-f]{64}$' "" \ + 'image: vault\.habana\.ai/gaudi-docker/1\.24\.2/ubuntu24\.04/habanalabs/vllm-0\.30\.0-ptupstream-2\.13\.0:[^@ ]+@sha256:[0-9a-f]{64}$' "" \ STUB_ALLOC="${HPU_ALLOC}" agent_says "K3D_GPU_AGENT_IMAGE overrides the Gaudi image" "${r}" 0 \ 'image: example/vllm-gaudi:dev$' "" STUB_ALLOC="${HPU_ALLOC}" K3D_GPU_AGENT_IMAGE=example/vllm-gaudi:dev @@ -3567,7 +3567,7 @@ addon_says "stt NPU pod joins the accel node's group" "${r}" 0 ovms-stt \ addon_says "video GPU pod joins the render group" "${r}" 0 video-analytics \ "supplementalGroups: \[${rgid}\]" "" STUB_ALLOC="${SHARED}" addon_says "add-ons run the OVMS image" "${r}" 0 ovms-stt \ - 'image: openvino/model_server@sha256:[0-9a-f]{64}' "" STUB_ALLOC="${SHARED}" + 'image: openvino/model_server:[^@ ]+@sha256:[0-9a-f]{64}' "" STUB_ALLOC="${SHARED}" addon_says "one unshared iGPU, no XMX: the LLM is on CPU and video gets the iGPU" "${r}" 0 video-analytics \ 'k3d-gpu.io/agent-device: GPU' "" STUB_ALLOC="${ONE}" addon_says "one unshared iGPU, no XMX: vlm falls back to CPU" "${r}" 0 ovms-vlm \ @@ -3658,7 +3658,7 @@ addon_says "vllm on 2 of 3 GPU units leaves video the third" "${r}" 0 video-anal addon_says "... and vlm none" "${r}" 0 ovms-vlm \ '--target_device=CPU$' "" STUB_ALLOC="${XE3}" K3D_GPU_AGENT_BACKEND=vllm K3D_GPU_AGENT_GPUS=2 addon_says "an add-on next to vllm runs the OVMS image, not vLLM's" "${r}" 0 ovms-vlm \ - 'image: openvino/model_server@sha256:[0-9a-f]{64}' "" STUB_ALLOC="${XE3}" K3D_GPU_AGENT_BACKEND=vllm K3D_GPU_AGENT_GPUS=2 + 'image: openvino/model_server:[^@ ]+@sha256:[0-9a-f]{64}' "" STUB_ALLOC="${XE3}" K3D_GPU_AGENT_BACKEND=vllm K3D_GPU_AGENT_GPUS=2 addon_says "a Gaudi LLM leaves the Intel GPU to video" "${r}" 0 video-analytics \ 'k3d-gpu.io/agent-device: GPU' "" STUB_ALLOC=$'cpu 32\nhabana.ai/gaudi 1\ngpu.intel.com/i915 1' @@ -3708,7 +3708,7 @@ addon_says "CPU video leaves decoder ranks alone" "${r}" 0 video-analytics \ addon_says "CPU video requests no GPU" "${r}" 0 video-analytics \ 'gpu\.intel\.com' negate STUB_ALLOC='cpu 32' addon_says "the DL Streamer image is pinned by digest" "${r}" 0 video-analytics \ - 'image: intel/dlstreamer@sha256:[0-9a-f]{64}' "" STUB_ALLOC="${SHARED}" + 'image: intel/dlstreamer:[^@ ]+@sha256:[0-9a-f]{64}' "" STUB_ALLOC="${SHARED}" addon_says "the model is checked against its sha256" "${r}" 0 video-analytics \ 'sha256sum -c' "" STUB_ALLOC="${SHARED}" addon_says "the sample clip is fetched by default" "${r}" 0 video-analytics \ @@ -4551,17 +4551,35 @@ fi # readyReplicas), STUB_SANDBOX_PODS (running sandbox pods) and STUB_SANDBOX_CPUS # (the Cpus_allowed_list a sandbox pod reports). A call whose arguments match the # ERE in STUB_FAIL prints nothing and exits 1. +# The installed agent-sandbox: STUB_CONTROLLER_IMAGE is the controller Deployment's +# image (empty: no Deployment), STUB_CONTROLLER_LABEL its app.kubernetes.io/managed-by +# label and STUB_CONTROLLER_HELM its meta.helm.sh/release-name annotation. The four +# CRDs exist unless STUB_NO_CRDS is set; each serves STUB_CRD_SERVED (default v1beta1) +# and carries STUB_CRD_LABEL and STUB_CRD_HELM. STUB_FOREIGN lists sandbox objects +# cluster-wide as "Kind namespace/name" entries split by ';', STUB_SYSTEM_PODS the pods +# in agent-sandbox-system other than the controller's. SSTUB="${WORK}/sbin" mkdir -p "${SSTUB}" cat > "${SSTUB}/kubectl" <<'EOF' #!/bin/sh while [ "${1:-}" = --context ]; do shift 2; done -echo "$*" >> "${STUB_LOG}" -if [ -n "${STUB_FAIL:-}" ] && echo "$*" | grep -Eq -- "${STUB_FAIL}"; then +# printf, not echo: dash's echo (/bin/sh on Ubuntu) turns a jsonpath's \n into a line break. +printf '%s\n' "$*" >> "${STUB_LOG}" +if [ -n "${STUB_FAIL:-}" ] && printf '%s\n' "$*" | grep -Eq -- "${STUB_FAIL}"; then echo "error: stub failure" >&2 exit 1 fi case "$*" in + *"get deployment agent-sandbox-controller"*) + [ -z "${STUB_CONTROLLER_IMAGE:-}" ] \ + || printf '%s|%s|%s' "${STUB_CONTROLLER_IMAGE}" "${STUB_CONTROLLER_LABEL:-}" "${STUB_CONTROLLER_HELM:-}" ;; + *"get crd "*) + [ -n "${STUB_NO_CRDS:-}" ] || for c in sandboxes.agents.x-k8s.io sandboxclaims.extensions.agents.x-k8s.io \ + sandboxtemplates.extensions.agents.x-k8s.io sandboxwarmpools.extensions.agents.x-k8s.io; do + printf '%s|%s|%s|%s\n' "${c}" "${STUB_CRD_SERVED-v1beta1}" "${STUB_CRD_LABEL:-}" "${STUB_CRD_HELM:-}" + done ;; + *" -A -o "*) [ -z "${STUB_FOREIGN:-}" ] || printf '%s\n' "${STUB_FOREIGN}" | tr ';' '\n' ;; + *"get pods -l app!=agent-sandbox-controller"*) for p in ${STUB_SYSTEM_PODS:-}; do echo "pod/${p}"; done ;; *"get nodes -l"*) for n in ${STUB_HYBRID_NODES:-}; do echo "node/${n}"; done ;; *"get sandboxwarmpool "*) printf %s "${STUB_POOL_READY-1}" ;; *"get pods -l k3d-gpu.io/cores=efficient"*) for p in ${STUB_SANDBOX_PODS:-}; do echo "${p}"; done ;; @@ -4574,14 +4592,15 @@ SHARE="$(cd "$(dirname "$0")/.." && pwd)/share" # sandbox_says <name> <root> <want rc: 0|fail> <ERE> <action> [negate] [env assignments...]: # output of `k3d-gpu sandbox <action>` must match <ERE>, or must not after "negate". -# The kubectl call log is in ${WORK}/kubectl.log. +# <action> is split on spaces ("up --adopt"). The kubectl call log is in ${WORK}/kubectl.log. sandbox_says() { - local name=$1 root=$2 want_rc=$3 re=$4 action=$5 negate="" out rc hit=0 + local name=$1 root=$2 want_rc=$3 re=$4 action=$5 negate="" out rc hit=0 acts=() shift 5 if [ "${1:-}" = negate ]; then negate=1; shift; fi + read -ra acts <<< "${action}" : > "${WORK}/kubectl.log" out=$(env K3D_GPU_SYSROOT="${root}" K3D_GPU_SHARE="${SHARE}" STUB_LOG="${WORK}/kubectl.log" \ - PATH="${SSTUB}:${PATH}" "$@" bash "${K3D_GPU}" sandbox "${action}" 2>&1) + PATH="${SSTUB}:${PATH}" "$@" bash "${K3D_GPU}" sandbox ${acts[@]+"${acts[@]}"} 2>&1) rc=$? grep -E -- "${re}" <<< "${out}" >/dev/null && hit=1 [ -z "${negate}" ] || hit=$((1 - hit)) @@ -4675,6 +4694,154 @@ log_order "sandbox down deletes the profile before the CRDs" \ 'delete -f .*/agent-sandbox-k3d\.yml --ignore-not-found' 'delete -f .*/agent-sandbox\.yml --ignore-not-found' sandbox_says "sandbox with no action prints usage and fails" "${r}" fail 'usage: k3d-gpu sandbox up\|down\|status' "" +# Ownership. The controller is k3d-gpu's when it runs the image pinned in +# share/agent-sandbox.yml or carries app.kubernetes.io/managed-by=k3d-gpu; another +# installer's (Intel's EAT installs v0.5.0 into agent-sandbox-system) is left alone. +PINNED=$(sed -n 's/^ *image: \(.*\/agent-sandbox-controller[:@].*\)$/\1/p' "${SHARE}/agent-sandbox.yml") +THEIRS=registry.k8s.io/agent-sandbox/agent-sandbox-controller:v0.5.0 +UP_OK=(STUB_SANDBOX_PODS=p1 STUB_SANDBOX_CPUS=16) +# first_calls <name> <ERE>...: the first kubectl calls match these, one each, in order. +first_calls() { + local name=$1 i=1 re + shift + for re in "$@"; do + if ! sed -n "${i}p" "${WORK}/kubectl.log" | grep -Eq -- "${re}"; then + FAILS=$((FAILS + 1)); echo "FAIL ${name} (call ${i} is not /${re}/)" + sed 's/^/ | /' "${WORK}/kubectl.log" + return + fi + i=$((i + 1)) + done + PASS=$((PASS + 1)); echo "ok ${name}" +} +[ -n "${PINNED}" ] || { FAILS=$((FAILS + 1)); echo "FAIL the controller image is readable from share/agent-sandbox.yml"; } + +sandbox_says "sandbox up on a fresh cluster installs agent-sandbox" "${r}" 0 'Installing agent-sandbox' up "${UP_OK[@]}" +first_calls "sandbox up on a fresh cluster adds two reads (controller, CRDs) before today's apply" \ + '^-n agent-sandbox-system get deployment agent-sandbox-controller --ignore-not-found -o jsonpath=' \ + '^get crd sandboxes\.agents\.x-k8s\.io .* --ignore-not-found -o jsonpath=' \ + 'apply -f .*/agent-sandbox\.yml$' +log_order "sandbox up labels what it installed once the controller is rolled out" \ + 'rollout status deployment/agent-sandbox-controller' \ + 'label --overwrite deployment/agent-sandbox-controller namespace/agent-sandbox-system crd/sandboxes\.agents\.x-k8s\.io .* app\.kubernetes\.io/managed-by=k3d-gpu$' \ + 'apply -f .*/agent-sandbox-k3d\.yml$' +sandbox_says "sandbox up re-applies its own controller (pinned image)" "${r}" 0 'Installing agent-sandbox' up "${UP_OK[@]}" \ + STUB_CONTROLLER_IMAGE="${PINNED}" +log_order "sandbox up on its own controller re-applies the manifest and the profile" \ + 'apply -f .*/agent-sandbox\.yml$' 'apply -f .*/agent-sandbox-k3d\.yml$' +# k3d-gpu releases before the label installed v1.0.7 with no managed-by label. V107_TAG +# is the same tag with no digest, a controller that only the label can make k3d-gpu's. +V107=registry.k8s.io/agent-sandbox/agent-sandbox-controller:v1.0.7@sha256:f254384c24605436ce35d7077427559e24bda8f5691cff5a54a4c2e030d81083 +V107_TAG=${V107%@*} +sandbox_says "sandbox up upgrades a controller labelled managed-by=k3d-gpu" "${r}" 0 'Installing agent-sandbox' up "${UP_OK[@]}" \ + STUB_CONTROLLER_IMAGE="${V107_TAG}" STUB_CONTROLLER_LABEL=k3d-gpu +log_order "sandbox up on a labelled older controller applies the manifest" 'apply -f .*/agent-sandbox\.yml$' +sandbox_says "sandbox up upgrades the unlabelled v1.0.7 controller an earlier k3d-gpu installed" "${r}" 0 'Installing agent-sandbox' up "${UP_OK[@]}" \ + STUB_CONTROLLER_IMAGE="${V107}" +log_order "sandbox up on k3d-gpu's unlabelled v1.0.7 applies the manifest, then labels it" \ + 'apply -f .*/agent-sandbox\.yml$' 'label --overwrite deployment/agent-sandbox-controller' +sandbox_says "sandbox up treats v1.0.7 by tag alone as another installer's" "${r}" fail 'not k3d-gpu' up \ + STUB_CONTROLLER_IMAGE="${V107_TAG}" +sandbox_says "sandbox up refuses another installer's controller and names its image" "${r}" fail \ + "agent-sandbox-controller runs ${THEIRS}" up "${UP_OK[@]}" STUB_CONTROLLER_IMAGE="${THEIRS}" +no_call "sandbox up applies no controller manifest over another installer's" 'apply -f .*/agent-sandbox\.yml$' +no_call "sandbox up applies no profile over another installer's controller" 'apply -f .*/agent-sandbox-k3d\.yml$' +no_call "sandbox up labels nothing it did not install" 'label --overwrite' +sandbox_says "sandbox up offers --adopt for another installer's controller" "${r}" fail \ + "k3d-gpu sandbox up --adopt" up STUB_CONTROLLER_IMAGE="${THEIRS}" +sandbox_says "sandbox up treats a Helm release as another installer's, even on the pinned image" "${r}" fail \ + 'managed-by Helm, Helm release agent-sandbox' up STUB_CONTROLLER_IMAGE="${PINNED}" STUB_CONTROLLER_LABEL=Helm STUB_CONTROLLER_HELM=agent-sandbox +no_call "sandbox up applies nothing over a Helm release" 'apply -f' +sandbox_says "sandbox up stops when it cannot read the controller" "${r}" fail 'could not read' up \ + STUB_FAIL='get deployment agent-sandbox-controller' +no_call "sandbox up applies nothing when it cannot read the controller" 'apply -f' +sandbox_says "sandbox up stops when it cannot read the CRDs" "${r}" fail 'could not read the agent-sandbox CRDs' up \ + STUB_FAIL='get crd ' +no_call "sandbox up applies nothing when it cannot read the CRDs" 'apply -f' +# CRDs another installer marked, with no controller (a Helm release whose controller is +# gone): installing over them would relabel them k3d-gpu's. +sandbox_says "sandbox up refuses CRDs from a Helm release and names them" "${r}" fail \ + "CRD sandboxes\.agents\.x-k8s\.io is another installer's \(managed-by Helm, Helm release agent-sandbox\)" up \ + STUB_CRD_LABEL=Helm STUB_CRD_HELM=agent-sandbox +no_call "sandbox up applies nothing over another installer's CRDs" 'apply -f' +no_call "sandbox up labels no CRDs another installer marked" 'label --overwrite' +sandbox_says "sandbox up refuses CRDs labelled managed-by another installer" "${r}" fail \ + "is another installer's \(managed-by agentic-ai-stack\)" up STUB_CRD_LABEL=agentic-ai-stack +sandbox_says "sandbox up re-applies CRDs labelled managed-by=k3d-gpu" "${r}" 0 'Installing agent-sandbox' up "${UP_OK[@]}" \ + STUB_CONTROLLER_IMAGE="${PINNED}" STUB_CONTROLLER_LABEL=k3d-gpu STUB_CRD_LABEL=k3d-gpu +sandbox_says "sandbox up --adopt leaves Helm's CRDs to Helm" "${r}" 0 'adopting' "up --adopt" "${UP_OK[@]}" \ + STUB_CONTROLLER_IMAGE="${THEIRS}" STUB_CRD_LABEL=Helm STUB_CRD_HELM=agent-sandbox +no_call "sandbox up --adopt applies no CRDs over Helm's" 'apply -f .*/agent-sandbox\.yml$' + +sandbox_says "sandbox up --adopt keeps another installer's controller" "${r}" 0 "adopting .*${THEIRS}" "up --adopt" "${UP_OK[@]}" \ + STUB_CONTROLLER_IMAGE="${THEIRS}" +log_order "sandbox up --adopt checks the CRDs, waits for them and the controller, then applies the profile" \ + 'get deployment agent-sandbox-controller' \ + 'get crd .*sandboxtemplates\.extensions\.agents\.x-k8s\.io.*sandboxwarmpools\.extensions\.agents\.x-k8s\.io' \ + 'wait --for=condition=Established' \ + 'rollout status deployment/agent-sandbox-controller' \ + 'apply -f .*/agent-sandbox-k3d\.yml$' \ + 'get sandboxwarmpool python-ecores' +no_call "sandbox up --adopt applies no controller manifest" 'apply -f .*/agent-sandbox\.yml$' +no_call "sandbox up --adopt labels nothing" 'label --overwrite' +sandbox_says "sandbox up --adopt fails when the CRDs do not serve v1beta1" "${r}" fail \ + 'sandboxtemplates\.extensions\.agents\.x-k8s\.io does not serve extensions\.agents\.x-k8s\.io/v1beta1 \(served: v1alpha1\)' \ + "up --adopt" STUB_CONTROLLER_IMAGE="${THEIRS}" STUB_CRD_SERVED=v1alpha1 +no_call "sandbox up --adopt applies no profile on CRDs without v1beta1" 'apply -f' +sandbox_says "sandbox up --adopt fails when the CRDs are missing" "${r}" fail 'does not serve .*\(served: none\)' \ + "up --adopt" STUB_CONTROLLER_IMAGE="${THEIRS}" STUB_NO_CRDS=1 +no_call "sandbox up --adopt applies no profile without CRDs" 'apply -f' +sandbox_says "sandbox up --adopt with no controller fails and installs nothing" "${r}" fail 'no agent-sandbox controller to adopt' "up --adopt" +no_call "sandbox up --adopt with no controller applies nothing" 'apply -f' +sandbox_says "sandbox up --adopt on its own controller applies only the profile" "${r}" 0 'adopting' "up --adopt" "${UP_OK[@]}" \ + STUB_CONTROLLER_IMAGE="${PINNED}" +no_call "sandbox up --adopt on its own controller applies no manifest" 'apply -f .*/agent-sandbox\.yml$' +log_order "sandbox up --adopt on its own controller applies the profile" 'apply -f .*/agent-sandbox-k3d\.yml$' +sandbox_says "sandbox down --adopt is a usage error" "${r}" fail "--adopt goes with 'sandbox up'" "down --adopt" +no_call "sandbox down --adopt deletes nothing" 'delete' +sandbox_says "sandbox status --adopt is a usage error" "${r}" fail "--adopt goes with 'sandbox up'" "status --adopt" +sandbox_says "sandbox up with an unknown option fails" "${r}" fail 'unknown sandbox option: --bogus' "up --bogus" +sandbox_says "sandbox usage names --adopt" "${r}" fail 'up --adopt: keep another installer' "" +if bash "${K3D_GPU}" help 2>&1 | grep -E '^ sandbox up \[--adopt\]\|down\|status$' >/dev/null; then + PASS=$((PASS + 1)); echo "ok help lists sandbox up [--adopt]" +else + FAILS=$((FAILS + 1)); echo "FAIL help lists sandbox up [--adopt]" +fi + +# down: the profile always goes; the namespace, CRDs and controller only when k3d-gpu +# owns the controller and nothing else lives on them. +sandbox_says "sandbox down on its own controller removes agent-sandbox" "${r}" 0 'Removing agent-sandbox' down \ + STUB_CONTROLLER_IMAGE="${PINNED}" STUB_FOREIGN='SandboxWarmPool agent-sandbox/python-ecores;Sandbox agent-sandbox/python-ecores-x1' +log_order "sandbox down on its own controller deletes the profile, then the manifest" \ + 'delete -f .*/agent-sandbox-k3d\.yml --ignore-not-found' 'delete -f .*/agent-sandbox\.yml --ignore-not-found' +sandbox_says "sandbox down removes the unlabelled v1.0.7 controller an earlier k3d-gpu installed" "${r}" 0 'Removing agent-sandbox' down \ + STUB_CONTROLLER_IMAGE="${V107}" +log_order "sandbox down on k3d-gpu's v1.0.7 deletes the manifest" 'delete -f .*/agent-sandbox\.yml --ignore-not-found' +sandbox_says "sandbox down keeps another installer's controller and names its image" "${r}" 0 \ + "Kept namespace agent-sandbox-system, the agent-sandbox CRDs and the controller" down STUB_CONTROLLER_IMAGE="${THEIRS}" +sandbox_says "sandbox down names the other installer's image" "${r}" 0 "controller runs ${THEIRS}" down STUB_CONTROLLER_IMAGE="${THEIRS}" +log_order "sandbox down still deletes the profile next to another installer's controller" \ + 'delete -f .*/agent-sandbox-k3d\.yml --ignore-not-found' +no_call "sandbox down deletes no manifest under another installer's controller" 'delete -f .*/agent-sandbox\.yml' +sandbox_says "sandbox down keeps agent-sandbox while another installer's warm pool uses it" "${r}" 0 \ + 'SandboxWarmPool agent-sandbox-system/python-pool' down \ + STUB_CONTROLLER_IMAGE="${PINNED}" STUB_FOREIGN='SandboxWarmPool agent-sandbox/python-ecores;SandboxWarmPool agent-sandbox-system/python-pool' +no_call "sandbox down deletes no manifest under another installer's sandboxes" 'delete -f .*/agent-sandbox\.yml' +sandbox_says "sandbox down keeps agent-sandbox-system while other pods run in it" "${r}" 0 \ + 'pod/sandbox-router-7d9f in agent-sandbox-system' down STUB_CONTROLLER_IMAGE="${PINNED}" STUB_SYSTEM_PODS=sandbox-router-7d9f +no_call "sandbox down deletes no namespace other pods run in" 'delete -f .*/agent-sandbox\.yml' +sandbox_says "sandbox down keeps CRDs from a Helm release" "${r}" 0 'Helm release agent-sandbox' down \ + STUB_CRD_LABEL=Helm STUB_CRD_HELM=agent-sandbox +no_call "sandbox down deletes no Helm-owned CRDs" 'delete -f .*/agent-sandbox\.yml' +sandbox_says "sandbox down stops when it cannot read the controller" "${r}" fail 'could not read' down \ + STUB_FAIL='get deployment agent-sandbox-controller' +no_call "sandbox down deletes no manifest when it cannot read the controller" 'delete -f .*/agent-sandbox\.yml' +sandbox_says "sandbox down stops when it cannot list sandbox objects" "${r}" fail 'could not list' down \ + STUB_CONTROLLER_IMAGE="${PINNED}" STUB_FAIL=' -A -o ' +no_call "sandbox down deletes no manifest when it cannot list sandbox objects" 'delete -f .*/agent-sandbox\.yml' +sandbox_says "sandbox down with no CRDs lists no sandbox objects" "${r}" 0 'Removing agent-sandbox' down STUB_NO_CRDS=1 +no_call "sandbox down with no CRDs runs no -A list" ' -A ' + # The shipped profile: sandboxes are labelled for E-cores, request CPU (requests size # the balloon) and every image is pinned by digest. manifest_has() { diff --git a/tests/eat_test.sh b/tests/eat_test.sh index 0e6b1872..9864ea34 100755 --- a/tests/eat_test.sh +++ b/tests/eat_test.sh @@ -20,7 +20,9 @@ # additive only, and prints each change against EAT stock. # 7. The pins for EAT release-1.1.0: the tag and commit eat-up.sh clones, the # components agentic-config.cfg turns off, brownfield.sh handing the config's -# deploy_guardrails to EAT's DEPLOY_GUARDRAILS, and the vLLM CPU image by digest. +# deploy_guardrails to EAT's DEPLOY_GUARDRAILS, and the vLLM CPU image by digest, +# for the guard too: deploy-guardrails.yml alone gets a chart copy whose +# guardrail-values.yaml carries vllm-values.yaml's digest (guard-values.py). # Needs bash, jq, python3 (with PyYAML). set -euo pipefail @@ -598,6 +600,26 @@ BF="${WORK}/bf" mkdir -p "${BF}/bin" "${BF}/home" cp -r "${FIX}" "${BF}/eat" echo 'cluster_url=api.example.com' > "${BF}/eat/core/inventory/agentic-config.cfg" +# EAT 0c726f5 core/helm-charts/vllm/guardrail-values.yaml, cut to the keys around its +# image, and a chart file: deploy-guardrails.yml installs that chart with these values. +mkdir -p "${BF}/eat/core/helm-charts/vllm/templates" +cat > "${BF}/eat/core/helm-charts/vllm/guardrail-values.yaml" <<'EOF' +fullnameOverride: vllm-guard + +LLM_MODEL_ID: "Qwen/Qwen3Guard-Gen-0.6B" +cpu: 2 +memory: 16Gi + +image: + repository: vllm/vllm-openai-cpu + tag: "v0.28.0" + pullPolicy: IfNotPresent + +deploymentStrategy: + type: Recreate +EOF +echo 'name: vllm' > "${BF}/eat/core/helm-charts/vllm/Chart.yaml" +echo 'image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"' > "${BF}/eat/core/helm-charts/vllm/templates/deployment.yaml" cat > "${BF}/eat/deploy-agentic-stack.sh" <<'EOF' # EAT 0c726f5 deploy-agentic-stack.sh:195 runs this at source time; only its main() # reads deploy_guardrails from the config, and fresh-install.sh:419 gates on this one. @@ -611,6 +633,10 @@ brownfield_deployment() { cd "${CORE_DIR}" ansible-playbook -i hosts playbooks/deploy-ingress-controller.yml --extra-vars "secret_name=api.example.com" ansible-playbook -i hosts playbooks/deploy-genai-gateway.yml --extra-vars "secret_name=api.example.com" --vault-password-file v + # fresh-install.sh:419 and guardrails-controller.sh:29-35 at 0c726f5. + if [[ "${DEPLOY_GUARDRAILS:-off}" == "on" ]]; then + ansible-playbook -i hosts playbooks/deploy-guardrails.yml --extra-vars "hugging_face_token=hf guardrail_model=Qwen/Qwen3Guard-Gen-0.6B" --vault-password-file v + fi } EOF cat > "${BF}/bin/sudo" <<'EOF' @@ -631,7 +657,7 @@ EOF chmod +x "${BF}/bin/"* : > "${BF}/log" out=$(env PATH="${BF}/bin:${PATH}" HOME="${BF}/home" STUB_LOG="${BF}/log" EAT_DIR="${BF}/eat" \ - EAT_KUBECONFIG="${WORK}/kubeconfig" K3D_GPU_EAT_DIR="${EAT}" bash "${EAT}/brownfield.sh" 2>&1) && rc=0 || rc=$? + EAT_KUBECONFIG="${WORK}/kubeconfig" EAT_VLLM_VALUES="${EAT}/vllm-values.yaml" K3D_GPU_EAT_DIR="${EAT}" bash "${EAT}/brownfield.sh" 2>&1) && rc=0 || rc=$? log=$(cat "${BF}/log") if [ "${rc}" -eq 0 ] && grep -qx 'answers 5 1 yes' <<< "${log}"; then ok "brownfield.sh stack: runs with the same menu answers" else fail "brownfield.sh stack rc=${rc}: ${out} ${log}"; fi @@ -659,7 +685,7 @@ bf_guardrails() { printf '%s\n' 'cluster_url=api.example.com' "$@" > "${BF}/eat/core/inventory/agentic-config.cfg" : > "${BF}/log" out=$(env PATH="${BF}/bin:${PATH}" HOME="${BF}/home" STUB_LOG="${BF}/log" EAT_DIR="${BF}/eat" \ - EAT_KUBECONFIG="${WORK}/kubeconfig" K3D_GPU_EAT_DIR="${EAT}" bash "${EAT}/brownfield.sh" 2>&1) && rc=0 || rc=$? + EAT_KUBECONFIG="${WORK}/kubeconfig" EAT_VLLM_VALUES="${EAT}/vllm-values.yaml" K3D_GPU_EAT_DIR="${EAT}" bash "${EAT}/brownfield.sh" 2>&1) && rc=0 || rc=$? got=$(sed -n 's/^guardrails //p' "${BF}/log") if [ "${rc}" -eq 0 ] && [ "${got}" = "${want}" ]; then ok "brownfield.sh guardrail ${want}: ${name}" else fail "brownfield.sh guardrail ${name}: rc=${rc}, DEPLOY_GUARDRAILS='${got}', want ${want}: ${out}"; fi @@ -678,10 +704,99 @@ echo 'cluster_url=api.example.com' > "${BF}/eat/core/inventory/agentic-config.cf echo 'deploy_genai_gateway=on' >> "${BF}/eat/core/inventory/agentic-config.cfg" sed -i 's#playbooks/deploy-genai-gateway.yml#playbooks/deploy-genai-gateway-renamed.yaml#' "${BF}/eat/deploy-agentic-stack.sh" out=$(env PATH="${BF}/bin:${PATH}" HOME="${BF}/home" STUB_LOG="${BF}/log" EAT_DIR="${BF}/eat" \ - EAT_KUBECONFIG="${WORK}/kubeconfig" K3D_GPU_EAT_DIR="${EAT}" bash "${EAT}/brownfield.sh" 2>&1) && rc=0 || rc=$? + EAT_KUBECONFIG="${WORK}/kubeconfig" EAT_VLLM_VALUES="${EAT}/vllm-values.yaml" K3D_GPU_EAT_DIR="${EAT}" bash "${EAT}/brownfield.sh" 2>&1) && rc=0 || rc=$? if [ "${rc}" -ne 0 ] && grep -q 'never ran through the pinning wrapper' <<< "${out}"; then ok "brownfield.sh stack: fails when the gateway playbook bypasses the pins" else fail "brownfield.sh stack: bypass not caught (rc=${rc}): ${out}"; fi +sed -i 's#playbooks/deploy-genai-gateway-renamed.yaml#playbooks/deploy-genai-gateway.yml#' "${BF}/eat/deploy-agentic-stack.sh" + +# 7c. The guard's image by digest. deploy-guardrails.yml (0c726f5, lines 115 and 133-135) +# installs {{ remote_helm_charts_base }}/vllm with its guardrail-values.yaml, which +# names vllm/vllm-openai-cpu:v0.28.0 by tag only. brownfield.sh hands that playbook, +# and only it, a copy of EAT's charts whose guardrail-values.yaml carries the digest +# vllm-values.yaml pins for the same image, before anything deploys. +GUARD_SRC="${BF}/eat/core/helm-charts/vllm/guardrail-values.yaml" +cp "${GUARD_SRC}" "${WORK}/guardrail-values.orig.yaml" +# bf_stack <config lines after cluster_url...>: one stack run; sets out, rc and log. +bf_stack() { + printf '%s\n' 'cluster_url=api.example.com' "$@" > "${BF}/eat/core/inventory/agentic-config.cfg" + : > "${BF}/log" + out=$(env PATH="${BF}/bin:${PATH}" HOME="${BF}/home" STUB_LOG="${BF}/log" EAT_DIR="${BF}/eat" \ + EAT_KUBECONFIG="${WORK}/kubeconfig" EAT_VLLM_VALUES="${VV:-${EAT}/vllm-values.yaml}" K3D_GPU_EAT_DIR="${EAT}" \ + bash "${EAT}/brownfield.sh" 2>&1) && rc=0 || rc=$? + log=$(cat "${BF}/log") +} +pinned_tag=$(python3 -c 'import sys, yaml; print(yaml.safe_load(open(sys.argv[1]))["image"]["tag"])' "${EAT}/vllm-values.yaml") +rm -rf "${BF}/home/k3d-gpu-guard-charts" +bf_stack 'deploy_guardrails=on' +guard_call=$(grep '^ansible-playbook .*deploy-guardrails.yml' <<< "${log}" || true) +copy="${BF}/home/k3d-gpu-guard-charts" +if [ "${rc}" -eq 0 ] && grep -Eq -- "--extra-vars @${BF}/home/[^ ]+\.yml" <<< "${guard_call}" \ + && grep -qx "remote_helm_charts_base: ${copy}" <<< "${log}"; then + ok "brownfield.sh guardrail on: deploy-guardrails.yml gets the chart copy as remote_helm_charts_base" +else fail "brownfield.sh guardrail on (rc=${rc}): guard call '${guard_call}': ${out}"; sed 's/^/ | /' <<< "${log}"; fi +if out2=$(python3 - "${copy}/vllm/guardrail-values.yaml" "${WORK}/guardrail-values.orig.yaml" "${pinned_tag}" 2>&1 <<'EOF' +import sys, yaml +got, orig, tag = yaml.safe_load(open(sys.argv[1])), yaml.safe_load(open(sys.argv[2])), sys.argv[3] +assert tag.startswith("v0.28.0@sha256:"), tag +assert got["image"] == {**orig["image"], "tag": tag}, got["image"] +assert {k: v for k, v in got.items() if k != "image"} == {k: v for k, v in orig.items() if k != "image"}, got +EOF +); then ok "brownfield.sh guardrail on: the copy's guardrail-values.yaml names vllm-values.yaml's v0.28.0 digest, every other key as EAT ships it" +else fail "brownfield.sh guardrail on: copied guardrail-values.yaml: ${out2}"; fi +if cmp -s "${GUARD_SRC}" "${WORK}/guardrail-values.orig.yaml" && cmp -s "${BF}/eat/core/helm-charts/vllm/Chart.yaml" "${copy}/vllm/Chart.yaml" \ + && cmp -s "${BF}/eat/core/helm-charts/vllm/templates/deployment.yaml" "${copy}/vllm/templates/deployment.yaml"; then + ok "brownfield.sh guardrail on: EAT's checkout is not edited, and the rest of the chart is copied as is" +else fail "brownfield.sh guardrail on: EAT's guardrail-values.yaml or the chart copy differs"; fi +if [ "$(grep -c '^remote_helm_charts_base: ' <<< "${log}")" -eq 1 ] \ + && ! grep '^ansible-playbook ' <<< "${log}" | grep -v 'deploy-guardrails.yml' | grep -q 'guard'; then + ok "brownfield.sh guardrail on: no other playbook gets the guard's chart copy" +else fail "brownfield.sh guardrail on: the guard vars reached another playbook"; sed 's/^/ | /' <<< "${log}"; fi +rm -rf "${copy}" +mapfile -t shipped < "${EAT}/agentic-config.cfg" +bf_stack "${shipped[@]}" +if [ "${rc}" -eq 0 ] && ! grep -q 'deploy-guardrails.yml' <<< "${log}" && [ ! -e "${copy}" ]; then + ok "brownfield.sh guardrail off (shipped config): no guard playbook and no chart copy" +else fail "brownfield.sh guardrail off (rc=${rc}): ${out}"; fi +# bf_guard_refuses <name> <expected text> <guardrail-values.yaml image tag line> [vllm values file] +bf_guard_refuses() { + local name=$1 want=$2 line=$3 + VV=${4:-} + sed "s#^ tag: \"v0.28.0\"\$#${line}#" "${WORK}/guardrail-values.orig.yaml" > "${GUARD_SRC}" + bf_stack 'deploy_guardrails=on' + if [ "${rc}" -ne 0 ] && grep -qF -- "${want}" <<< "${out}" && ! grep -q '^answers' <<< "${log}"; then + ok "brownfield.sh guardrail on: stops before deploying on ${name}" + else fail "brownfield.sh guardrail on, ${name} (rc=${rc}): ${out}"; sed 's/^/ | /' <<< "${log}"; fi + cp "${WORK}/guardrail-values.orig.yaml" "${GUARD_SRC}" + VV= +} +bf_guard_refuses "a guard tag other than vllm-values.yaml's" 'v0.29.0' ' tag: "v0.29.0"' +bf_guard_refuses "a guard tag already carrying a digest" 'v0.28.0@sha256:0000' \ + ' tag: "v0.28.0@sha256:0000000000000000000000000000000000000000000000000000000000000000"' +printf 'image:\n repository: vllm/vllm-openai-xpu\n tag: "%s"\n' "${pinned_tag}" > "${WORK}/vv-repo.yaml" +bf_guard_refuses "vLLM values for another repository" 'vllm/vllm-openai-xpu' ' tag: "v0.28.0"' "${WORK}/vv-repo.yaml" +printf 'image:\n repository: vllm/vllm-openai-cpu\n tag: "v0.28.0"\n' > "${WORK}/vv-tag.yaml" +bf_guard_refuses "vLLM values with no digest" 'not <tag>@sha256:<digest>' ' tag: "v0.28.0"' "${WORK}/vv-tag.yaml" +printf 'memory: 16Gi\n' > "${WORK}/vv-none.yaml" +bf_guard_refuses "vLLM values with no image" 'no image' ' tag: "v0.28.0"' "${WORK}/vv-none.yaml" +# A guard playbook the wrapper never sees (the guard on) fails the deploy. +sed -i 's#playbooks/deploy-guardrails.yml#playbooks/deploy-guardrails-renamed.yaml#' "${BF}/eat/deploy-agentic-stack.sh" +bf_stack 'deploy_guardrails=on' +if [ "${rc}" -ne 0 ] && grep -qF 'deploy-guardrails.yml never ran through' <<< "${out}"; then + ok "brownfield.sh stack: fails when the guard playbook bypasses the pin" +else fail "brownfield.sh stack: guard bypass not caught (rc=${rc}): ${out}"; fi +sed -i 's#playbooks/deploy-guardrails-renamed.yaml#playbooks/deploy-guardrails.yml#' "${BF}/eat/deploy-agentic-stack.sh" +bf_stack 'deploy_guardrails=off' +if [ "${rc}" -eq 0 ] && ! grep -q 'deploy-guardrails' <<< "${log}"; then + ok "brownfield.sh stack: the guard pin check stays quiet with the guard off" +else fail "brownfield.sh stack: guard off (rc=${rc}): ${out}"; fi +# EAT calling the guard playbook although the guard was off when the copy was due. +sed -i 's#if \[\[ "${DEPLOY_GUARDRAILS:-off}" == "on" \]\]; then#if true; then#' "${BF}/eat/deploy-agentic-stack.sh" +bf_stack 'deploy_guardrails=off' +if [ "${rc}" -ne 0 ] && grep -qF 'no pinned chart copy' <<< "${out}" && ! grep -q '^ansible-playbook .*deploy-guardrails.yml' <<< "${log}"; then + ok "brownfield.sh stack: a guard playbook call without the chart copy does not run" +else fail "brownfield.sh stack: unexpected guard call (rc=${rc}): ${out}"; sed 's/^/ | /' <<< "${log}"; fi +sed -i 's#if true; then#if [[ "${DEPLOY_GUARDRAILS:-off}" == "on" ]]; then#' "${BF}/eat/deploy-agentic-stack.sh" # 6. eat-opt.py: the model optimizer as values on EAT's own entry, printed against # stock. EAT's playbook and values are reduced to the lines eat-opt.py reads diff --git a/tests/fake_ovms.py b/tests/fake_ovms.py index 8206ec11..67b824b7 100755 --- a/tests/fake_ovms.py +++ b/tests/fake_ovms.py @@ -10,6 +10,10 @@ base 2.0 ms/token; PERFORMANCE_HINT THROUGHPUT x0.5; KV_CACHE_PRECISION u8 x0.8; k3d-gpu.io/cores: efficient x1.5. FAKE_OVMS_FLAT=1 ignores the config (a null server). Every answer contains the quality probes' expected strings, so quality is 5/5. + +For canary/run.sh it also serves GET /v3/models (the model "canary"), GET /v2 (server +metadata, version FAKE_OVMS_VERSION) and POST /v3/completions (text completions of +FAKE_OVMS_TEXT, plain or streamed, finish_reason "length"). """ import http.server @@ -42,12 +46,45 @@ class Handler(http.server.BaseHTTPRequestHandler): def log_message(self, *a): pass + def send_json(self, obj): + out = json.dumps(obj).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(out))) + self.end_headers() + self.wfile.write(out) + def do_GET(self): - self.send_response(200 if self.path.startswith("/v2/health") else 404) + if self.path == "/v3/models": + self.send_json({"object": "list", "data": [{"id": "canary", "object": "model", "owned_by": "OVMS"}]}) + elif self.path == "/v2": + self.send_json({"name": "OpenVINO Model Server", "version": os.environ.get("FAKE_OVMS_VERSION", "2026.4.1.cfc9ac721")}) + else: + self.send_response(200 if self.path.startswith("/v2/health") else 404) + self.end_headers() + + def completions(self, body): + text = os.environ.get("FAKE_OVMS_TEXT", " Paris. The capital of Italy is Rome") + words = text.split(" ") + words = [w if i == 0 else " " + w for i, w in enumerate(words)][:int(body.get("max_tokens", 8))] + usage = {"prompt_tokens": 5, "completion_tokens": len(words), "total_tokens": 5 + len(words)} + if not body.get("stream"): + self.send_json({"choices": [{"index": 0, "text": "".join(words), "finish_reason": "length"}], + "object": "text_completion", "usage": usage}) + return + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") self.end_headers() + for w in [""] + words: + self.wfile.write(b"data: " + json.dumps({"choices": [{"index": 0, "text": w, "finish_reason": None}]}).encode() + b"\n\n") + self.wfile.write(b"data: " + json.dumps({"choices": [{"index": 0, "text": "", "finish_reason": "length"}]}).encode() + b"\n\n") + self.wfile.write(b"data: [DONE]\n\n") def do_POST(self): body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + if self.path == "/v3/completions": + self.completions(body) + return n = int(body.get("max_tokens", 8)) if not body.get("stream"): out = json.dumps({"choices": [{"message": {"role": "assistant", "content": ANSWER}}], diff --git a/tests/fixtures/alderlake-n/facts.json b/tests/fixtures/alderlake-n/facts.json index 84e6cc79..2fb68a26 100644 --- a/tests/fixtures/alderlake-n/facts.json +++ b/tests/fixtures/alderlake-n/facts.json @@ -317,8 +317,8 @@ "release": { "k3d_gpu_version": "<version>", "pins": { - "llama.cpp": "ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", - "ovms": "openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" + "llama.cpp": "ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", + "ovms": "openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" } }, "schema": "k3d-gpu.facts/v1", diff --git a/tests/fixtures/alderlake-n/report.html b/tests/fixtures/alderlake-n/report.html index c10c381e..c33e18c1 100644 --- a/tests/fixtures/alderlake-n/report.html +++ b/tests/fixtures/alderlake-n/report.html @@ -67,9 +67,9 @@ <h2>Detected</h2> <h2>Node labels</h2> <div class="wrap"><table><tbody><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVXVNNI</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.CETIBT</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.GFNI</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.SHA</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.VAES</th><td>true</td></tr><tr><th>gpu.intel.com/xmx</th><td>false</td></tr></tbody></table></div> <h2>Release pins</h2> -<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> +<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> <footer> -<p>Rendered from facts/v1, sha256 of its canonical bytes <code>2b930cbab2911420b480d2b923c3dbb95c88201b46725e043e68ed9cf13952c1</code>, and plan/v1.</p> +<p>Rendered from facts/v1, sha256 of its canonical bytes <code>6526f9de70feefdcb01adbe44b60479a20e2efb66e77fb8cee36fc2733fe5499</code>, and plan/v1.</p> <p>This page runs no script and loads nothing: its Content-Security-Policy is <code>default-src 'none'</code>. The links are references to follow, not resources it fetches.</p> <p><a href="https://github.com/88plug/k3d-gpu" rel="noreferrer">https://github.com/88plug/k3d-gpu</a></p> </footer></main></body></html> diff --git a/tests/fixtures/arrowlake-h/facts.json b/tests/fixtures/arrowlake-h/facts.json index 6d5f132f..73d6d6d7 100644 --- a/tests/fixtures/arrowlake-h/facts.json +++ b/tests/fixtures/arrowlake-h/facts.json @@ -415,8 +415,8 @@ "release": { "k3d_gpu_version": "<version>", "pins": { - "llama.cpp": "ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", - "ovms": "openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" + "llama.cpp": "ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", + "ovms": "openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" } }, "schema": "k3d-gpu.facts/v1", diff --git a/tests/fixtures/arrowlake-h/report.html b/tests/fixtures/arrowlake-h/report.html index 2c67e60a..d22a34c2 100644 --- a/tests/fixtures/arrowlake-h/report.html +++ b/tests/fixtures/arrowlake-h/report.html @@ -67,9 +67,9 @@ <h2>Detected</h2> <h2>Node labels</h2> <div class="wrap"><table><tbody><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVXVNNI</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.GFNI</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.SHA</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.VAES</th><td>true</td></tr><tr><th>gpu.intel.com/xmx</th><td>true</td></tr><tr><th>k3d-gpu.io/hybrid-cpu</th><td>true</td></tr><tr><th>k3d-gpu.io/npu.gen</th><td>37xx</td></tr></tbody></table></div> <h2>Release pins</h2> -<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> +<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> <footer> -<p>Rendered from facts/v1, sha256 of its canonical bytes <code>8532aa7e7bd7b67ce97a3755cb09013288658046f8157b5bc36f252af64c7ccb</code>, and plan/v1.</p> +<p>Rendered from facts/v1, sha256 of its canonical bytes <code>43ae038c6efdd1e41e8f635c74b586cb3ed0ff09ffe6a9b3c1d4a62798e9511e</code>, and plan/v1.</p> <p>This page runs no script and loads nothing: its Content-Security-Policy is <code>default-src 'none'</code>. The links are references to follow, not resources it fetches.</p> <p><a href="https://github.com/88plug/k3d-gpu" rel="noreferrer">https://github.com/88plug/k3d-gpu</a></p> </footer></main></body></html> diff --git a/tests/fixtures/arrowlake-s/facts.json b/tests/fixtures/arrowlake-s/facts.json index 4a35f064..b4259a98 100644 --- a/tests/fixtures/arrowlake-s/facts.json +++ b/tests/fixtures/arrowlake-s/facts.json @@ -418,8 +418,8 @@ "release": { "k3d_gpu_version": "<version>", "pins": { - "llama.cpp": "ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", - "ovms": "openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" + "llama.cpp": "ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", + "ovms": "openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" } }, "schema": "k3d-gpu.facts/v1", diff --git a/tests/fixtures/arrowlake-s/report.html b/tests/fixtures/arrowlake-s/report.html index a30d9b97..3c031041 100644 --- a/tests/fixtures/arrowlake-s/report.html +++ b/tests/fixtures/arrowlake-s/report.html @@ -67,9 +67,9 @@ <h2>Detected</h2> <h2>Node labels</h2> <div class="wrap"><table><tbody><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVXVNNI</th><td>true</td></tr><tr><th>gpu.intel.com/media-vcs</th><td>2</td></tr><tr><th>gpu.intel.com/media-vecs</th><td>1</td></tr><tr><th>gpu.intel.com/xmx</th><td>false</td></tr><tr><th>k3d-gpu.io/hybrid-cpu</th><td>true</td></tr><tr><th>k3d-gpu.io/npu.gen</th><td>37xx</td></tr></tbody></table></div> <h2>Release pins</h2> -<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> +<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> <footer> -<p>Rendered from facts/v1, sha256 of its canonical bytes <code>ddb625631dd4de5919780361ffa6fd879f38947329be0186865e98b877ba1b74</code>, and plan/v1.</p> +<p>Rendered from facts/v1, sha256 of its canonical bytes <code>b156c482a75ef930c97f31dff50c0468caacf22380df2e5d1cfa89c1efe62503</code>, and plan/v1.</p> <p>This page runs no script and loads nothing: its Content-Security-Policy is <code>default-src 'none'</code>. The links are references to follow, not resources it fetches.</p> <p><a href="https://github.com/88plug/k3d-gpu" rel="noreferrer">https://github.com/88plug/k3d-gpu</a></p> </footer></main></body></html> diff --git a/tests/fixtures/arrowlake-u/facts.json b/tests/fixtures/arrowlake-u/facts.json index 186481b6..4493a9df 100644 --- a/tests/fixtures/arrowlake-u/facts.json +++ b/tests/fixtures/arrowlake-u/facts.json @@ -317,8 +317,8 @@ "release": { "k3d_gpu_version": "<version>", "pins": { - "llama.cpp": "ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", - "ovms": "openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" + "llama.cpp": "ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", + "ovms": "openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" } }, "schema": "k3d-gpu.facts/v1", diff --git a/tests/fixtures/arrowlake-u/report.html b/tests/fixtures/arrowlake-u/report.html index ebbcdbcd..50c728df 100644 --- a/tests/fixtures/arrowlake-u/report.html +++ b/tests/fixtures/arrowlake-u/report.html @@ -67,9 +67,9 @@ <h2>Detected</h2> <h2>Node labels</h2> <div class="wrap"><table><tbody><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVXVNNI</th><td>true</td></tr><tr><th>gpu.intel.com/xmx</th><td>false</td></tr><tr><th>k3d-gpu.io/hybrid-cpu</th><td>true</td></tr></tbody></table></div> <h2>Release pins</h2> -<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> +<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> <footer> -<p>Rendered from facts/v1, sha256 of its canonical bytes <code>d40f851699cb171012acbe6a5511e9a1d83ccefbe2a36546c5ccedefeab67325</code>, and plan/v1.</p> +<p>Rendered from facts/v1, sha256 of its canonical bytes <code>d7ec644aa6f89fbd206297293f7aa5ee3bfb043d076ee47615ebdd48bdebec43</code>, and plan/v1.</p> <p>This page runs no script and loads nothing: its Content-Security-Policy is <code>default-src 'none'</code>. The links are references to follow, not resources it fetches.</p> <p><a href="https://github.com/88plug/k3d-gpu" rel="noreferrer">https://github.com/88plug/k3d-gpu</a></p> </footer></main></body></html> diff --git a/tests/fixtures/battlemage-b580/facts.json b/tests/fixtures/battlemage-b580/facts.json index bb98b189..a6c3cc55 100644 --- a/tests/fixtures/battlemage-b580/facts.json +++ b/tests/fixtures/battlemage-b580/facts.json @@ -288,8 +288,8 @@ "release": { "k3d_gpu_version": "<version>", "pins": { - "llama.cpp": "ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", - "ovms": "openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" + "llama.cpp": "ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", + "ovms": "openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" } }, "schema": "k3d-gpu.facts/v1", diff --git a/tests/fixtures/battlemage-b580/report.html b/tests/fixtures/battlemage-b580/report.html index ea7a601a..0fdbc562 100644 --- a/tests/fixtures/battlemage-b580/report.html +++ b/tests/fixtures/battlemage-b580/report.html @@ -67,9 +67,9 @@ <h2>Detected</h2> <h2>Node labels</h2> <div class="wrap"><table><tbody><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512BF16</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512F</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512VNNI</th><td>true</td></tr><tr><th>gpu.intel.com/xmx</th><td>true</td></tr></tbody></table></div> <h2>Release pins</h2> -<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> +<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> <footer> -<p>Rendered from facts/v1, sha256 of its canonical bytes <code>ec0ab6e41e8662ac11862a2beaa021cdecc4d15db3861849e7e4113d0eadc6c6</code>, and plan/v1.</p> +<p>Rendered from facts/v1, sha256 of its canonical bytes <code>41aac371300c61925593ac6215959ee6896388694ba99a90ede273902f91d5ff</code>, and plan/v1.</p> <p>This page runs no script and loads nothing: its Content-Security-Policy is <code>default-src 'none'</code>. The links are references to follow, not resources it fetches.</p> <p><a href="https://github.com/88plug/k3d-gpu" rel="noreferrer">https://github.com/88plug/k3d-gpu</a></p> </footer></main></body></html> diff --git a/tests/fixtures/battlemage-pro-b60/facts.json b/tests/fixtures/battlemage-pro-b60/facts.json index c2824e9a..cddbb3f0 100644 --- a/tests/fixtures/battlemage-pro-b60/facts.json +++ b/tests/fixtures/battlemage-pro-b60/facts.json @@ -297,8 +297,8 @@ "release": { "k3d_gpu_version": "<version>", "pins": { - "llama.cpp": "ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", - "ovms": "openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" + "llama.cpp": "ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", + "ovms": "openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" } }, "schema": "k3d-gpu.facts/v1", diff --git a/tests/fixtures/battlemage-pro-b60/report.html b/tests/fixtures/battlemage-pro-b60/report.html index 3dd4cac5..c37cb6ba 100644 --- a/tests/fixtures/battlemage-pro-b60/report.html +++ b/tests/fixtures/battlemage-pro-b60/report.html @@ -67,9 +67,9 @@ <h2>Detected</h2> <h2>Node labels</h2> <div class="wrap"><table><tbody><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512BF16</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512F</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512VNNI</th><td>true</td></tr><tr><th>gpu.intel.com/xmx</th><td>true</td></tr></tbody></table></div> <h2>Release pins</h2> -<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> +<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> <footer> -<p>Rendered from facts/v1, sha256 of its canonical bytes <code>26a17082890ca4743e794b8d4bf1bae8458d83debe6d1d73af5a20328958d1e2</code>, and plan/v1.</p> +<p>Rendered from facts/v1, sha256 of its canonical bytes <code>d9283c3923e2a30aa3eff1bac81b1ba1720b1d652594c9f33a4ea8596288d889</code>, and plan/v1.</p> <p>This page runs no script and loads nothing: its Content-Security-Policy is <code>default-src 'none'</code>. The links are references to follow, not resources it fetches.</p> <p><a href="https://github.com/88plug/k3d-gpu" rel="noreferrer">https://github.com/88plug/k3d-gpu</a></p> </footer></main></body></html> diff --git a/tests/fixtures/battlemage-pro-b70-k616/facts.json b/tests/fixtures/battlemage-pro-b70-k616/facts.json index a766e272..7b5cc607 100644 --- a/tests/fixtures/battlemage-pro-b70-k616/facts.json +++ b/tests/fixtures/battlemage-pro-b70-k616/facts.json @@ -233,8 +233,8 @@ "release": { "k3d_gpu_version": "<version>", "pins": { - "llama.cpp": "ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", - "ovms": "openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" + "llama.cpp": "ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", + "ovms": "openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" } }, "schema": "k3d-gpu.facts/v1", diff --git a/tests/fixtures/battlemage-pro-b70-k616/report.html b/tests/fixtures/battlemage-pro-b70-k616/report.html index f8d261ef..fc9c066f 100644 --- a/tests/fixtures/battlemage-pro-b70-k616/report.html +++ b/tests/fixtures/battlemage-pro-b70-k616/report.html @@ -67,9 +67,9 @@ <h2>Detected</h2> <h2>Node labels</h2> <div class="wrap"><table><tbody><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512BF16</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512F</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512VNNI</th><td>true</td></tr></tbody></table></div> <h2>Release pins</h2> -<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> +<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> <footer> -<p>Rendered from facts/v1, sha256 of its canonical bytes <code>bd676f6c5297b327cac5efe2eaa30cb446ac281ca3bad518f044341cff2021df</code>, and plan/v1.</p> +<p>Rendered from facts/v1, sha256 of its canonical bytes <code>46a4c46208c352a55f3cca0dd29e066a41ed0b5f4a247a3007169b9ef176d768</code>, and plan/v1.</p> <p>This page runs no script and loads nothing: its Content-Security-Policy is <code>default-src 'none'</code>. The links are references to follow, not resources it fetches.</p> <p><a href="https://github.com/88plug/k3d-gpu" rel="noreferrer">https://github.com/88plug/k3d-gpu</a></p> </footer></main></body></html> diff --git a/tests/fixtures/canary/README.md b/tests/fixtures/canary/README.md new file mode 100644 index 00000000..e27f72fb --- /dev/null +++ b/tests/fixtures/canary/README.md @@ -0,0 +1,30 @@ +# Canary fixtures + +Inputs for `tests/canary_surface_test.py` and `tests/canary_test.sh`, taken from the +upstream releases k3d-gpu pins on 2026-10-29. They are copied verbatim; the tests read +them, nothing imports them. + +`vllm-0.32.0/` is a site-packages tree of vLLM v0.32.0 (tag v0.32.0, commit +5df63a7203cdb9328d824a27b682e77c7b9928be, Apache-2.0, SPDX headers kept): + +| File | Source | +|---|---| +| `vllm/engine/arg_utils.py` | `vllm/engine/arg_utils.py` at v0.32.0 | +| `vllm/envs.py` | `vllm/envs.py` at v0.32.0 | +| `vllm/entrypoints/launchers/cli_args.py` | `vllm/entrypoints/launchers/cli_args.py` at v0.32.0 (the frontend flags moved here from `entrypoints/openai/cli_args.py`) | +| `vllm/__init__.py` | empty: the package marker only | +| `vllm-0.32.0+cpu.dist-info/METADATA` | the two header lines of the `vllm-openai-cpu:v0.32.0` image's metadata | + +`help/` holds the help output of the pinned images, run with no device: + +| File | Command | +|---|---| +| `vllm-0.32.0-cpu-serve-help-all.txt` | `vllm/vllm-openai-cpu:v0.32.0` `vllm serve --help=all` | +| `llama-server-b11960-help.txt` | `ghcr.io/ggml-org/llama.cpp:server-b11960` `llama-server --help` (commit 8642541b255bfb5dc39cd351a178be09013a55d6) | +| `ovms-2026.4.1-help.txt` | `openvino/model_server:2026.4.1-gpu` `ovms --help` | +| `sglang-0.5.22-xeon-help.txt` | `lmsysorg/sglang:v0.5.22-xeon` `python3 -m sglang.launch_server --help` (stdout and stderr, its deprecation warning included) | + +`sglang-0.5.22/` is the part of the `lmsysorg/sglang:v0.5.22-xeon` image's site-packages +that the surface check reads for the version: `sglang/_version.py` verbatim, an empty +`sglang/__init__.py`, and the three header lines of `sglang_cpu-0.5.22.dist-info/METADATA` +(the distribution is `sglang-cpu`, so the version comes from `_version.py`). diff --git a/tests/fixtures/canary/help/llama-server-b11960-help.txt b/tests/fixtures/canary/help/llama-server-b11960-help.txt new file mode 100644 index 00000000..79ff5a6b --- /dev/null +++ b/tests/fixtures/canary/help/llama-server-b11960-help.txt @@ -0,0 +1,753 @@ +----- common params ----- + +-h, --help, --usage print usage and exit +--version show version and build info +-cl, --cache-list show list of models in cache +--completion-bash print source-able bash completion script for llama.cpp +-t, --threads N number of CPU threads to use during generation (default: -1) + (env: LLAMA_ARG_THREADS) +-tb, --threads-batch N number of threads to use during batch and prompt processing (default: + same as --threads) +-C, --cpu-mask M CPU affinity mask: arbitrarily long hex. Complements cpu-range + (default: "") +-Cr, --cpu-range lo-hi range of CPUs for affinity. Complements --cpu-mask +--cpu-strict <0|1> use strict CPU placement (default: 0) +--prio N set process/thread priority : low(-1), normal(0), medium(1), high(2), + realtime(3) (default: 0) +--poll <0...100> use polling level to wait for work (0 - no polling, default: 50) +-Cb, --cpu-mask-batch M CPU affinity mask: arbitrarily long hex. Complements cpu-range-batch + (default: same as --cpu-mask) +-Crb, --cpu-range-batch lo-hi ranges of CPUs for affinity. Complements --cpu-mask-batch +--cpu-strict-batch <0|1> use strict CPU placement (default: same as --cpu-strict) +--prio-batch N set process/thread priority : 0-normal, 1-medium, 2-high, 3-realtime + (default: 0) +--poll-batch <0|1> use polling to wait for work (default: same as --poll) +-c, --ctx-size N size of the prompt context (default: 0, 0 = loaded from model) + (env: LLAMA_ARG_CTX_SIZE) +-n, --predict, --n-predict N number of tokens to predict (default: -1, -1 = infinity) + (env: LLAMA_ARG_N_PREDICT) +-b, --batch-size N logical maximum batch size (default: 2048) + (env: LLAMA_ARG_BATCH) +-ub, --ubatch-size N physical maximum batch size (default: 512) + (env: LLAMA_ARG_UBATCH) +--keep N number of tokens to keep from the initial prompt (default: 0, -1 = + all) +--swa-full use full-size SWA cache (default: false) + [(more + info)](https://github.com/ggml-org/llama.cpp/pull/13194#issuecomment-2868343055) + (env: LLAMA_ARG_SWA_FULL) +-fa, --flash-attn [on|off|auto] set Flash Attention use ('on', 'off', or 'auto', default: 'auto') + (env: LLAMA_ARG_FLASH_ATTN) +--perf, --no-perf whether to enable internal libllama performance timings (default: + false) + (env: LLAMA_ARG_PERF) +-e, --escape, --no-escape whether to process escapes sequences (\n, \r, \t, \', \", \\) + (default: true) +--rope-scaling {none,linear,yarn} RoPE frequency scaling method, defaults to linear unless specified by + the model + (env: LLAMA_ARG_ROPE_SCALING_TYPE) +--rope-scale N RoPE context scaling factor, expands context by a factor of N + (env: LLAMA_ARG_ROPE_SCALE) +--rope-freq-base N RoPE base frequency, used by NTK-aware scaling (default: loaded from + model) + (env: LLAMA_ARG_ROPE_FREQ_BASE) +--rope-freq-scale N RoPE frequency scaling factor, expands context by a factor of 1/N + (env: LLAMA_ARG_ROPE_FREQ_SCALE) +--yarn-orig-ctx N YaRN: original context size of model (default: 0 = model training + context size) + (env: LLAMA_ARG_YARN_ORIG_CTX) +--yarn-ext-factor N YaRN: extrapolation mix factor (default: -1.00, 0.0 = full + interpolation) + (env: LLAMA_ARG_YARN_EXT_FACTOR) +--yarn-attn-factor N YaRN: scale sqrt(t) or attention magnitude (default: -1.00) + (env: LLAMA_ARG_YARN_ATTN_FACTOR) +--yarn-beta-slow N YaRN: high correction dim or alpha (default: -1.00) + (env: LLAMA_ARG_YARN_BETA_SLOW) +--yarn-beta-fast N YaRN: low correction dim or beta (default: -1.00) + (env: LLAMA_ARG_YARN_BETA_FAST) +-kvo, --kv-offload, -nkvo, --no-kv-offload + whether to enable KV cache offloading (default: enabled) + (env: LLAMA_ARG_KV_OFFLOAD) +--repack, -nr, --no-repack whether to enable weight repacking (default: enabled) + (env: LLAMA_ARG_REPACK) +--no-host bypass host buffer allowing extra buffers to be used + (env: LLAMA_ARG_NO_HOST) +-ctk, --cache-type-k TYPE KV cache data type for K + allowed values: f32, f16, bf16, q8_0, q4_0, q4_1, iq4_nl, q5_0, q5_1 + (default: f16) + (env: LLAMA_ARG_CACHE_TYPE_K) +-ctv, --cache-type-v TYPE KV cache data type for V + allowed values: f32, f16, bf16, q8_0, q4_0, q4_1, iq4_nl, q5_0, q5_1 + (default: f16) + (env: LLAMA_ARG_CACHE_TYPE_V) +-dt, --defrag-thold N KV cache defragmentation threshold (DEPRECATED) + (env: LLAMA_ARG_DEFRAG_THOLD) +--rpc SERVERS comma-separated list of RPC servers (host:port) + (env: LLAMA_ARG_RPC) +-lm, --load-mode MODE model loading mode (default: auto) + - auto: mmap, unless a device does not support it + - none: no special loading mode + - mmap: memory-map model (if mmap disabled, slower load but may reduce + pageouts if not using mlock) + - mlock: force system to keep model in RAM rather than swapping or + compressing + - mmap+mlock: mmap + force system to keep model in RAM rather than + swapping or compressing + - dio: use DirectIO if available + + (env: LLAMA_ARG_LOAD_MODE) +-lzm, --lazy-mode MODE on-demand reading of certain tensors, for example per-layer embeddings + (default: auto) + - on: read the rows of such tensors from disk on demand instead of + keeping them resident (requires mmap) + - auto: on, but only for tensors larger than 4 GiB + - off: always keep them resident + (env: LLAMA_ARG_LAZY_MODE) +--numa TYPE attempt optimizations that help on some NUMA systems + - distribute: spread execution evenly over all nodes + - isolate: only spawn threads on CPUs on the node that execution + started on + - numactl: use the CPU map provided by numactl + if run without this previously, it is recommended to drop the system + page cache before using this + see https://github.com/ggml-org/llama.cpp/issues/1437 + (env: LLAMA_ARG_NUMA) +-dev, --device <dev1,dev2,..> comma-separated list of devices to use for offloading (none = don't + offload) + use --list-devices to see a list of available devices + (env: LLAMA_ARG_DEVICE) +--list-devices print list of available devices and exit +-ot, --override-tensor <tensor name pattern>=<buffer type>,... + override tensor buffer type + (env: LLAMA_ARG_OVERRIDE_TENSOR) +-cmoe, --cpu-moe keep all Mixture of Experts (MoE) weights in the CPU + (env: LLAMA_ARG_CPU_MOE) +-ncmoe, --n-cpu-moe N keep the Mixture of Experts (MoE) weights of the first N layers in the + CPU + (env: LLAMA_ARG_N_CPU_MOE) +--moe-cache-mib N GPU cache size in MiB for the MoE experts kept in the CPU. with + multiple GPUs, it is split among them like the layers (--tensor-split) + (default: 0, disabled) + (env: LLAMA_ARG_MOE_CACHE_MIB) +-ncffn, --n-cpu-ffn N keep the dense FFN weights of the first N layers in the CPU + (dense models; for MoE expert weights use --n-cpu-moe) + (env: LLAMA_ARG_N_CPU_FFN) +-ngl, --gpu-layers, --n-gpu-layers N max. number of layers to store in VRAM, either an exact number, + 'auto', or 'all' (default: auto) + (env: LLAMA_ARG_N_GPU_LAYERS) +-sm, --split-mode {none,layer,row,tensor} + how to split the model across multiple GPUs, one of: + - none: use one GPU only + - layer (default): split layers and KV across GPUs (pipelined) + - row: split weight across GPUs by rows (parallelized) + - tensor: split weights and KV across GPUs (parallelized, + EXPERIMENTAL) + (env: LLAMA_ARG_SPLIT_MODE) +-ts, --tensor-split N0,N1,N2,... fraction of the model to offload to each GPU, comma-separated list of + proportions, e.g. 3,1 + (env: LLAMA_ARG_TENSOR_SPLIT) +-mg, --main-gpu INDEX the GPU to use for the model (with split-mode = none), or for + intermediate results and KV (with split-mode = row) (default: 0) + (env: LLAMA_ARG_MAIN_GPU) +-fit, --fit [on|off] whether to adjust unset arguments to fit in device memory ('on' or + 'off', default: 'on') + (env: LLAMA_ARG_FIT) +-fitt, --fit-target MiB0,MiB1,MiB2,... + target margin per device for --fit, comma-separated list of values, + single value is broadcast across all devices, default: 1024 + (env: LLAMA_ARG_FIT_TARGET) +-fitc, --fit-ctx N minimum ctx size that can be set by --fit option, default: 4096 + (env: LLAMA_ARG_FIT_CTX) +--check-tensors check model tensor data for invalid values (default: false) +--override-kv KEY=TYPE:VALUE,... advanced option to override model metadata by key. to specify multiple + overrides, either use comma-separated values. + types: int, float, bool, str. example: --override-kv + tokenizer.ggml.add_bos_token=bool:false,tokenizer.ggml.add_eos_token=bool:false +--op-offload, --no-op-offload whether to offload host tensor operations to device (default: true) +--lora FNAME path to LoRA adapter (use comma-separated values to load multiple + adapters) +--lora-scaled FNAME:SCALE,... path to LoRA adapter with user defined scaling (format: + FNAME:SCALE,...) + note: use comma-separated values +--control-vector FNAME add a control vector + note: use comma-separated values to add multiple control vectors +--control-vector-scaled FNAME:SCALE,... + add a control vector with user defined scaling SCALE + note: use comma-separated values (format: FNAME:SCALE,...) +--control-vector-layer-range START END + layer range to apply the control vector(s) to, start and end inclusive +-m, --model FNAME model path to load + (env: LLAMA_ARG_MODEL) +-mu, --model-url MODEL_URL model download url (default: unused) + (env: LLAMA_ARG_MODEL_URL) +-dr, --docker-repo [<repo>/]<model>[:quant] + Docker Hub model repository. repo is optional, default to ai/. quant + is optional, default to :latest. + example: gemma3 + (default: unused) + (env: LLAMA_ARG_DOCKER_REPO) +-hf, -hfr, --hf-repo <user>/<model>[:quant] + Hugging Face model repository; quant is optional, case-insensitive, + default to Q4_K_M, or falls back to the first file in the repo if + Q4_K_M doesn't exist. + mmproj is also downloaded automatically if available. to disable, add + --no-mmproj + example: ggml-org/GLM-4.7-Flash-GGUF:Q4_K_M + (default: unused) + (env: LLAMA_ARG_HF_REPO) +-hff, --hf-file FILE Hugging Face model file. If specified, it will override the quant in + --hf-repo (default: unused) + (env: LLAMA_ARG_HF_FILE) +-hft, --hf-token TOKEN Hugging Face access token (default: value from HF_TOKEN environment + variable) + (env: HF_TOKEN) +--log-disable Log disable +--log-file FNAME Log to file + (env: LLAMA_ARG_LOG_FILE) +--log-jsonl, --no-log-jsonl Log as JSONL (one JSON object per line) to stdout, this also disables + colored logging (default: disabled) + (env: LLAMA_ARG_LOG_JSONL) +--log-colors [on|off|auto] Set colored logging ('on', 'off', or 'auto', default: 'auto') + 'auto' enables colors when output is to a terminal + (env: LLAMA_ARG_LOG_COLORS) +-v, --verbose, --log-verbose Set verbosity level to infinity (i.e. log all messages, useful for + debugging) +--offline Offline mode: forces use of cache, prevents network access + (env: LLAMA_ARG_OFFLINE) +-lv, --verbosity, --log-verbosity N Set the verbosity threshold. Messages with a higher verbosity will be + ignored. Values: + - 0: generic output + - 1: error + - 2: warning + - 3: info + - 4: trace (more info) + - 5: debug + (default: 3) + + (env: LLAMA_ARG_LOG_VERBOSITY) +--log-prefix, --no-log-prefix Enable prefix in log messages + (env: LLAMA_ARG_LOG_PREFIX) +--log-timestamps, --no-log-timestamps Enable timestamps in log messages + (env: LLAMA_ARG_LOG_TIMESTAMPS) +--spec-draft-type-k, -ctkd, --cache-type-k-draft TYPE + KV cache data type for K for the draft model + allowed values: f32, f16, bf16, q8_0, q4_0, q4_1, iq4_nl, q5_0, q5_1 + (default: f16) + (env: LLAMA_ARG_SPEC_DRAFT_CACHE_TYPE_K) +--spec-draft-type-v, -ctvd, --cache-type-v-draft TYPE + KV cache data type for V for the draft model + allowed values: f32, f16, bf16, q8_0, q4_0, q4_1, iq4_nl, q5_0, q5_1 + (default: f16) + (env: LLAMA_ARG_SPEC_DRAFT_CACHE_TYPE_V) + + +----- sampling params ----- + +--samplers SAMPLERS samplers that will be used for generation in the order, separated by + ';' + (default: + penalties;dry;top_n_sigma;top_k;typ_p;top_p;min_p;xtc;temperature) +-s, --seed SEED RNG seed (default: -1, use random seed for -1) +--sampler-seq, --sampling-seq SEQUENCE + simplified sequence for samplers that will be used (default: + edskypmxt) +--ignore-eos ignore end of stream token and continue generating (implies + --logit-bias EOS-inf) +--temp, --temperature N temperature (default: 0.80) + (env: LLAMA_ARG_TEMPERATURE) +--top-k N top-k sampling (default: 40, 0 = disabled) + (env: LLAMA_ARG_TOP_K) +--top-p N top-p sampling (default: 0.95, 1.0 = disabled) + (env: LLAMA_ARG_TOP_P) +--min-p N min-p sampling (default: 0.05, 0.0 = disabled) + (env: LLAMA_ARG_MIN_P) +--top-nsigma, --top-n-sigma N top-n-sigma sampling (default: -1.00, -1.0 = disabled) +--xtc-probability N xtc probability (default: 0.00, 0.0 = disabled) +--xtc-threshold N xtc threshold (default: 0.10, 1.0 = disabled) +--typical, --typical-p N locally typical sampling, parameter p (default: 1.00, 1.0 = disabled) +--repeat-last-n N last n tokens to consider for penalize (default: 64, 0 = disabled) +--repeat-penalty N penalize repeat sequence of tokens (default: 1.00, 1.0 = disabled) + (env: LLAMA_ARG_REPEAT_PENALTY) +--presence-penalty N repeat alpha presence penalty (default: 0.00, 0.0 = disabled) + (env: LLAMA_ARG_PRESENCE_PENALTY) +--frequency-penalty N repeat alpha frequency penalty (default: 0.00, 0.0 = disabled) + (env: LLAMA_ARG_FREQUENCY_PENALTY) +--dry-multiplier N set DRY sampling multiplier (default: 0.00, 0.0 = disabled) +--dry-base N set DRY sampling base value (default: 1.75) +--dry-allowed-length N set allowed length for DRY sampling (default: 2) +--dry-penalty-last-n N set DRY penalty for the last n tokens (default: 64, 0 = disable) +--dry-sequence-breaker STRING add sequence breaker for DRY sampling, clearing out default breakers + ('\n', ':', '"', '*') in the process; use "none" to not use any + sequence breakers +--adaptive-target N adaptive-p: select tokens near this probability (valid range 0.0 to + 1.0; negative = disabled) (default: -1.00) + [(more info)](https://github.com/ggml-org/llama.cpp/pull/17927) +--adaptive-decay N adaptive-p: decay rate for target adaptation over time. lower values + are more reactive, higher values are more stable. + (valid range 0.0 to 0.99) (default: 0.90) +--dynatemp-range N dynamic temperature range (default: 0.00, 0.0 = disabled) +--dynatemp-exp N dynamic temperature exponent (default: 1.00) +--mirostat N use Mirostat sampling. + Top K, Nucleus and Locally Typical samplers are ignored if used. + (default: 0, 0 = disabled, 1 = Mirostat, 2 = Mirostat 2.0) +--mirostat-lr N Mirostat learning rate, parameter eta (default: 0.10) +--mirostat-ent N Mirostat target entropy, parameter tau (default: 5.00) +-l, --logit-bias TOKEN_ID(+/-)BIAS modifies the likelihood of token appearing in the completion, + i.e. `--logit-bias 15043+1` to increase likelihood of token ' Hello', + or `--logit-bias 15043-1` to decrease likelihood of token ' Hello' +--grammar GRAMMAR BNF-like grammar to constrain generations (see samples in grammars/ + dir) +--grammar-file FNAME file to read grammar from +-j, --json-schema SCHEMA JSON schema to constrain generations (https://json-schema.org/), e.g. + `{"type": "object"}` for any JSON object +-jf, --json-schema-file FILE File containing a JSON schema to constrain generations + (https://json-schema.org/), e.g. `{"type": "object"}` for any JSON + object +-bs, --backend-sampling enable backend sampling (experimental) (default: disabled) + (env: LLAMA_ARG_BACKEND_SAMPLING) + + +----- speculative params ----- + +--spec-draft-hf, -hfd, -hfrd, --hf-repo-draft <user>/<model>[:quant] + Same as --hf-repo, but for the draft model (default: unused) + (env: LLAMA_ARG_SPEC_DRAFT_HF_REPO) +--spec-draft-threads, -td, --threads-draft N + number of threads to use during generation (default: same as + --threads) +--spec-draft-threads-batch, -tbd, --threads-batch-draft N + number of threads to use during batch and prompt processing (default: + same as --threads-draft) +--spec-draft-cpu-mask, -Cd, --cpu-mask-draft M + Draft model CPU affinity mask. Complements cpu-range-draft (default: + same as --cpu-mask) +--spec-draft-cpu-range, -Crd, --cpu-range-draft lo-hi + Ranges of CPUs for affinity. Complements --cpu-mask-draft +--spec-draft-cpu-strict, --cpu-strict-draft <0|1> + Use strict CPU placement for draft model (default: same as + --cpu-strict) +--spec-draft-prio, --prio-draft N set draft process/thread priority : 0-normal, 1-medium, 2-high, + 3-realtime (default: 0) +--spec-draft-poll, --poll-draft <0|1> Use polling to wait for draft model work (default: same as --poll) +--spec-draft-cpu-mask-batch, -Cbd, --cpu-mask-batch-draft M + Draft model CPU affinity mask. Complements cpu-range-draft (default: + same as --cpu-mask) +--spec-draft-cpu-strict-batch, --cpu-strict-batch-draft <0|1> + Use strict CPU placement for draft model (default: --cpu-strict-draft) +--spec-draft-prio-batch, --prio-batch-draft N + set draft process/thread priority : 0-normal, 1-medium, 2-high, + 3-realtime (default: 0) +--spec-draft-poll-batch, --poll-batch-draft <0|1> + Use polling to wait for draft model work (default: --poll-draft) +--spec-draft-override-tensor, -otd, --override-tensor-draft <tensor name pattern>=<buffer type>,... + override tensor buffer type for draft model +--spec-draft-cpu-moe, -cmoed, --cpu-moe-draft + keep all Mixture of Experts (MoE) weights in the CPU for the draft + model + (env: LLAMA_ARG_SPEC_DRAFT_CPU_MOE) +--spec-draft-n-cpu-moe, --spec-draft-ncmoe, -ncmoed, --n-cpu-moe-draft N + keep the Mixture of Experts (MoE) weights of the first N layers in the + CPU for the draft model + (env: LLAMA_ARG_SPEC_DRAFT_N_CPU_MOE) +--spec-draft-n-max N number of tokens to draft for speculative decoding (default: 3) + (env: LLAMA_ARG_SPEC_DRAFT_N_MAX) +--spec-draft-n-min N minimum number of draft tokens to use for speculative decoding + (default: 0) + (env: LLAMA_ARG_SPEC_DRAFT_N_MIN) +--spec-draft-n-ubatch N physical maximum batch size of the draft context, use a smaller value + to spare VRAM (default: 0 - same as --n-ubatch) + (env: LLAMA_ARG_SPEC_DRAFT_N_UBATCH) +--spec-synth-len L target mean synthetic acceptance length, including the target token + (benchmarking only) + (env: LLAMA_ARG_SPEC_SYNTH_LEN) +--spec-synth-rates P0,P1,... comma-separated unconditional per-position synthetic acceptance + probabilities (benchmarking only) + (env: LLAMA_ARG_SPEC_SYNTH_RATES) +--spec-draft-p-split, --draft-p-split P + speculative decoding split probability (default: 0.10) + (env: LLAMA_ARG_SPEC_DRAFT_P_SPLIT) +--spec-draft-p-min, --draft-p-min P minimum speculative decoding probability (greedy) (default: 0.00) + (env: LLAMA_ARG_SPEC_DRAFT_P_MIN) +--spec-draft-backend-sampling, --no-spec-draft-backend-sampling + offload draft sampling to the backend (default: enabled) + (env: LLAMA_ARG_SPEC_DRAFT_BACKEND_SAMPLING) +--spec-draft-sampling {greedy,probabilistic} + how the draft is sampled: greedy takes its argmax, probabilistic + samples it and has the target verify by rejection sampling (default: + greedy) + (env: LLAMA_ARG_SPEC_DRAFT_SAMPLING) +--spec-draft-device, -devd, --device-draft <dev1,dev2,..> + comma-separated list of devices to use for offloading the draft model + (none = don't offload, default: follows --device) + use --list-devices to see a list of available devices +--spec-draft-ngl, -ngld, --gpu-layers-draft, --n-gpu-layers-draft N + max. number of draft model layers to store in VRAM, either an exact + number, 'auto', or 'all' (default: auto) + (env: LLAMA_ARG_N_GPU_LAYERS_DRAFT) +--spec-draft-model, -md, --model-draft FNAME + draft model for speculative decoding (default: unused) + (env: LLAMA_ARG_SPEC_DRAFT_MODEL) +--spec-type none,draft-simple,draft-eagle3,draft-mtp,draft-dflash,draft-dspark,ngram-simple,ngram-map-k,ngram-map-k4v,ngram-mod,ngram-cache + comma-separated list of types of speculative decoding to use (default: + none) + + (env: LLAMA_ARG_SPEC_TYPE) +--spec-ngram-mod-n-min N minimum number of ngram tokens to use for ngram-based speculative + decoding (default: 48) +--spec-ngram-mod-n-max N maximum number of ngram tokens to use for ngram-based speculative + decoding (default: 64) +--spec-ngram-mod-n-match N ngram-mod lookup length (default: 24) +--spec-ngram-simple-size-n N ngram size N for ngram-simple speculative decoding, length of lookup + n-gram (default: 12) +--spec-ngram-simple-size-m N ngram size M for ngram-simple speculative decoding, length of draft + m-gram (default: 48) +--spec-ngram-simple-min-hits N minimum hits for ngram-simple speculative decoding (default: 1) +--spec-ngram-map-k-size-n N ngram size N for ngram-map-k speculative decoding, length of lookup + n-gram (default: 12) +--spec-ngram-map-k-size-m N ngram size M for ngram-map-k speculative decoding, length of draft + m-gram (default: 48) +--spec-ngram-map-k-min-hits N minimum hits for ngram-map-k speculative decoding (default: 1) +--spec-ngram-map-k4v-size-n N ngram size N for ngram-map-k4v speculative decoding, length of lookup + n-gram (default: 12) +--spec-ngram-map-k4v-size-m N ngram size M for ngram-map-k4v speculative decoding, length of draft + m-gram (default: 48) +--spec-ngram-map-k4v-min-hits N minimum hits for ngram-map-k4v speculative decoding (default: 1) +--draft, --draft-n, --draft-max N the argument has been removed. use --spec-draft-n-max or + --spec-ngram-mod-n-max + (env: LLAMA_ARG_DRAFT_MAX) +--draft-min, --draft-n-min N the argument has been removed. use --spec-draft-n-min or + --spec-ngram-mod-n-min + (env: LLAMA_ARG_DRAFT_MIN) +--spec-ngram-size-n N the argument has been removed. use the respective + --spec-ngram-*-size-n or --spec-ngram-mod-n-match +--spec-ngram-size-m N the argument has been removed. use the respective + --spec-ngram-*-size-m +--spec-ngram-min-hits N the argument has been removed. use the respective + --spec-ngram-*-min-hits + + +----- example-specific params ----- + +-lcs, --lookup-cache-static FNAME path to static lookup cache to use for lookup decoding (not updated by + generation) +-lcd, --lookup-cache-dynamic FNAME path to dynamic lookup cache to use for lookup decoding (updated by + generation) +--kv-unified-per-slot N context limit per parallel slot (default: unset, behavior unchanged). + when set without -c/--ctx-size, the shared KV pool is sized to + n_parallel*N + (env: LLAMA_ARG_KV_UNIFIED_PER_SLOT) +-ctxcp, --ctx-checkpoints, --swa-checkpoints N + max number of context checkpoints to create per slot (default: + 32)[(more info)](https://github.com/ggml-org/llama.cpp/pull/15293) + (env: LLAMA_ARG_CTX_CHECKPOINTS) +-cms, --checkpoint-min-step N minimum spacing between context checkpoints in tokens (default: 8192, + 0 = no minimum) + (env: LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT) +-cram, --cache-ram N set the maximum cache size in MiB (default: 8192, 0 for decision + models, -1 - no limit, 0 - disable)[(more + info)](https://github.com/ggml-org/llama.cpp/pull/16391) + (env: LLAMA_ARG_CACHE_RAM) +-kvu, --kv-unified, -no-kvu, --no-kv-unified + use single unified KV buffer shared across all sequences (default: + enabled if number of slots is auto) + (env: LLAMA_ARG_KV_UNIFIED) +--cache-idle-slots, --no-cache-idle-slots + save idle slots to the prompt cache on new task, and clear them when + using unified KV (default: enabled, requires cache-ram) + (env: LLAMA_ARG_CACHE_IDLE_SLOTS) +--context-shift, --no-context-shift whether to use context shift on infinite text generation (default: + disabled) + (env: LLAMA_ARG_CONTEXT_SHIFT) +-r, --reverse-prompt PROMPT halt generation at PROMPT, return control in interactive mode +-sp, --special special tokens output enabled (default: false) +--warmup, --no-warmup whether to perform warmup with an empty run (default: enabled) +--spm-infill use Suffix/Prefix/Middle pattern for infill (instead of + Prefix/Suffix/Middle) as some models prefer this. (default: disabled) +--pooling {none,mean,cls,last,rank} pooling type for embeddings, use model default if unspecified + (env: LLAMA_ARG_POOLING) +-np, --parallel N number of server slots (default: -1, -1 = auto) + (env: LLAMA_ARG_N_PARALLEL) +-cb, --cont-batching, -nocb, --no-cont-batching + whether to enable continuous batching (a.k.a dynamic batching) + (default: enabled) + (env: LLAMA_ARG_CONT_BATCHING) +-mm, --mmproj FILE path to a multimodal projector file. see tools/mtmd/README.md + note: if -hf is used, this argument can be omitted + (env: LLAMA_ARG_MMPROJ) +-mmu, --mmproj-url URL URL to a multimodal projector file. see tools/mtmd/README.md + (env: LLAMA_ARG_MMPROJ_URL) +--mmproj-auto, --no-mmproj, --no-mmproj-auto + whether to use multimodal projector file (if available), useful when + using -hf (default: enabled) + (env: LLAMA_ARG_MMPROJ_AUTO) +--mmproj-offload, --no-mmproj-offload whether to enable GPU offloading for multimodal projector (default: + enabled) + (env: LLAMA_ARG_MMPROJ_OFFLOAD) +-mmdev, --mmproj-device DEVICE device to use for multimodal projector (none = don't offload, default: + follows --device) + use --list-devices to see a list of available devices + (env: MTMD_BACKEND_DEVICE) +--image-min-tokens N minimum number of tokens each image can take, only used by vision + models with dynamic resolution (default: read from model) + (env: LLAMA_ARG_IMAGE_MIN_TOKENS) +--image-max-tokens N maximum number of tokens each image can take, only used by vision + models with dynamic resolution (default: read from model) + (env: LLAMA_ARG_IMAGE_MAX_TOKENS) +--mtmd-batch-max-tokens N maximum number of image tokens per batch when encoding images + (default: 1024) + (env: LLAMA_ARG_MTMD_BATCH_MAX_TOKENS) +--video-fps N target video frame rate (default: 4.0) + (env: LLAMA_ARG_VIDEO_FPS) +--video-timestamp-interval N interval in milliseconds between text timestamps (default: 5000) + (env: LLAMA_ARG_VIDEO_TIMESTAMP_INTERVAL) +--video-ffmpeg-dir DIR path to the directory containing ffmpeg and ffprobe (default: search + in PATH) + (env: LLAMA_ARG_VIDEO_FFMPEG_DIR) +-a, --alias STRING set model name aliases, comma-separated (to be used by API) + (env: LLAMA_ARG_ALIAS) +--tags STRING set model tags, comma-separated (informational, not used for routing) + (env: LLAMA_ARG_TAGS) +--embd-normalize N normalisation for embeddings (default: 2) (-1=none, 0=max absolute + int16, 1=taxicab, 2=euclidean, >2=p-norm) +--host HOST IP addresses to listen on, comma-separated, or UNIX socket paths + ending in .sock; with multiple TCP addresses, :: binds IPv6 only; + overlapping addresses result in undefined behavior (default: + 127.0.0.1) + (env: LLAMA_ARG_HOST) +--port PORT port to listen (default: 9931) + (env: LLAMA_ARG_PORT) +--reuse-port allow multiple sockets to bind to the same port (default: disabled) + (env: LLAMA_ARG_REUSE_PORT) +--path PATH path to serve static files from (default: ) + (env: LLAMA_ARG_STATIC_PATH) +--cors-origins ORIGINS comma-separated list of allowed origins for CORS (default: *) + if set to special value 'localhost', reflect the Origin header only if + it is localhost + (env: LLAMA_ARG_CORS_ORIGINS) +--cors-methods METHODS comma-separated list of allowed methods for CORS (default: GET, POST, + DELETE, OPTIONS) + (env: LLAMA_ARG_CORS_METHODS) +--cors-headers HEADERS comma-separated list of allowed headers for CORS (default: *) + (env: LLAMA_ARG_CORS_HEADERS) +--cors-credentials, --no-cors-credentials + whether to allow credentials for CORS (default: enabled) + note: if this is enabled and --cors-origins is set to * (default), the + Origin header will be echoed back, and credentials will always be + allowed + (env: LLAMA_ARG_CORS_CREDENTIALS) +--api-prefix PREFIX prefix path the server serves from, without the trailing slash + (default: ) + (env: LLAMA_ARG_API_PREFIX) +--ui-config, --webui-config JSON JSON that provides default UI settings (overrides UI defaults) + (env: LLAMA_ARG_UI_CONFIG) +--ui-config-file, --webui-config-file PATH + JSON file that provides default UI settings (overrides UI defaults) + (env: LLAMA_ARG_UI_CONFIG_FILE) +--ui-mcp-proxy, --webui-mcp-proxy, --no-ui-mcp-proxy, --no-webui-mcp-proxy + experimental: whether to enable MCP CORS proxy - do not enable in + untrusted environments (default: disabled) + (env: LLAMA_ARG_UI_MCP_PROXY) +--tools TOOL1,TOOL2,... experimental: whether to enable built-in tools for AI agents - do not + enable in untrusted environments (default: no tools) + specify "all" to enable all tools + available tools: read_file, file_glob_search, grep_search, + exec_shell_command, write_file, edit_file, get_info + note: for security reasons, this will limit --cors-origins to + localhost by default + (env: LLAMA_ARG_TOOLS) +--tools-runtime OPTION experimental: run tools in a separate runtime environment (default: + none, use host environment) + available options: + 'docker:<image>', 'podman:<image>': spin up a new container and reuse + it for all invocations, clean up on server exit + 'docker-container:<id>', 'podman-container:<id>': use an existing + container by ID, won't stop on server exit + 'ssh:<target>': run tools on a remote POSIX host over SSH, key-based + auth and a trusted host key are required + + (env: LLAMA_ARG_TOOLS_RUNTIME) +--mcp-servers-config PATH experimental: path to JSON file with MCP server definitions + (Cursor-compatible format) - do not enable in untrusted environments + (default: none) + note: for security reasons, this will limit --cors-origins to + localhost by default + (env: LLAMA_ARG_MCP_SERVERS_CONFIG) +--mcp-servers-json JSON experimental: inline JSON with MCP server definitions + (Cursor-compatible format) - do not enable in untrusted environments + (default: none) + note: for security reasons, this will limit --cors-origins to + localhost by default + (env: LLAMA_ARG_MCP_SERVERS_JSON) +-ag, --agent, -no-ag, --no-agent whether to enable CORS proxy and all built-in tools - do not enable in + untrusted environments (default: disabled) + note: for security reasons, this will limit --cors-origins to + localhost by default + (env: LLAMA_ARG_AGENT) +--ui, --webui, --no-ui, --no-webui whether to enable the Web UI (default: enabled) + (env: LLAMA_ARG_UI) +--connect (experimental) open a peer-to-peer tunnel for server using + llama-connect (default: disabled) + (env: LLAMA_ARG_CONNECT) +--connect-code CODE manually specify a 40-character code for --connect (default: generate + a new one for each run) + (env: LLAMA_ARG_CONNECT_CODE) +--embedding, --embeddings restrict to only support embedding use case; use only with dedicated + embedding models (default: disabled) + (env: LLAMA_ARG_EMBEDDINGS) +--rerank, --reranking enable reranking endpoint on server (default: disabled) + (env: LLAMA_ARG_RERANKING) +--api-key KEY API key to use for authentication, multiple keys can be provided as a + comma-separated list (default: none) + (env: LLAMA_API_KEY) +--api-key-file FNAME path to file containing API keys, one per line; lines starting with a + hash are treated as comments (default: none) + (env: LLAMA_ARG_API_KEY_FILE) +--ssl-key-file FNAME path to file a PEM-encoded SSL private key + (env: LLAMA_ARG_SSL_KEY_FILE) +--ssl-cert-file FNAME path to file a PEM-encoded SSL certificate + (env: LLAMA_ARG_SSL_CERT_FILE) +--chat-template-kwargs STRING sets additional params for the json template parser, must be a valid + json object string, e.g. '{"key1":"value1","key2":"value2"}' + (env: LLAMA_ARG_CHAT_TEMPLATE_KWARGS) +-to, --timeout N server read/write timeout in seconds (default: 3600) + (env: LLAMA_ARG_TIMEOUT) +--sse-ping-interval N server SSE ping interval in seconds (-1 = disabled, default: 30) + (env: LLAMA_ARG_SSE_PING_INTERVAL) +--threads-http N number of threads used to process HTTP requests (default: -1) + (env: LLAMA_ARG_THREADS_HTTP) +--cache-prompt, --no-cache-prompt whether to enable prompt caching (default: enabled) + (env: LLAMA_ARG_CACHE_PROMPT) +--cache-reuse N min chunk size to attempt reusing from the cache via KV shifting, + requires prompt caching to be enabled (default: 0) + [(card)](https://ggml.ai/f0.png) + (env: LLAMA_ARG_CACHE_REUSE) +--metrics enable prometheus compatible metrics endpoint (default: disabled) + (env: LLAMA_ARG_ENDPOINT_METRICS) +--props enable changing global properties via POST /props (default: disabled) + (env: LLAMA_ARG_ENDPOINT_PROPS) +--slots, --no-slots expose slots monitoring endpoint (default: enabled) + (env: LLAMA_ARG_ENDPOINT_SLOTS) +--slot-save-path PATH path to save slot kv cache (default: disabled) + (env: LLAMA_ARG_SLOT_SAVE_PATH) +--media-path PATH directory for loading local media files; files can be accessed via + file:// URLs using relative paths (default: disabled) +--models-dir PATH directory containing models for the router server (default: disabled) + (env: LLAMA_ARG_MODELS_DIR) +--models-preset PATH path to INI file containing model presets for the router server + (default: disabled) + (env: LLAMA_ARG_MODELS_PRESET) +--models-max N for router server, maximum number of models to load simultaneously + (default: 4, 0 = unlimited) + (env: LLAMA_ARG_MODELS_MAX) +--models-memory-margin N for router server, MiB of device memory to keep free per device, + unload LRU models until a new one fits (default: 0, 0 = disabled) + (env: LLAMA_ARG_MODELS_MEMORY_MARGIN) +--models-autoload, --no-models-autoload + for router server, whether to automatically load models (default: + enabled) + (env: LLAMA_ARG_MODELS_AUTOLOAD) +--jinja, --no-jinja whether to use jinja template engine for chat (default: enabled) + (env: LLAMA_ARG_JINJA) +--reasoning-format FORMAT controls whether thought tags are allowed and/or extracted from the + response, and in which format they're returned; one of: + - none: leaves thoughts unparsed in `message.content` + - deepseek: puts thoughts in `message.reasoning_content` + - deepseek-legacy: keeps `<think>` tags in `message.content` while + also populating `message.reasoning_content` + (default: auto) + (env: LLAMA_ARG_THINK) +-rea, --reasoning [on|off|auto] Use reasoning/thinking in the chat ('on', 'off', or 'auto', default: + 'auto' (detect from template)) + (env: LLAMA_ARG_REASONING) +--reasoning-effort LEVEL reasoning effort level given to the chat template: 'default' to keep + the template default, + or a level such as 'minimal', 'low', 'medium', 'high', 'xhigh' or + 'max' (default: default) + (env: LLAMA_ARG_REASONING_EFFORT) +--reasoning-budget N token budget for thinking: -1 for unrestricted, 0 for immediate end, + N>0 for token budget (default: -1) + (env: LLAMA_ARG_THINK_BUDGET) +--reasoning-budget-message MESSAGE message injected before the end-of-thinking tag when reasoning budget + is exhausted (default: none) + (env: LLAMA_ARG_THINK_BUDGET_MESSAGE) +--reasoning-preserve, --no-reasoning-preserve + preserve reasoning trace in the full history, not just the last + assistant message (default: enabled) + compatible with certain templates having 'supports_preserve_reasoning' + capability + example: + https://docs.z.ai/guides/capabilities/thinking-mode#preserved-thinking + (env: LLAMA_ARG_REASONING_PRESERVE) +--chat-template JINJA_TEMPLATE set custom jinja chat template (default: template taken from model's + metadata) + if suffix/prefix are specified, template will be disabled + only commonly used templates are accepted (unless --jinja is set + before this flag): + list of built-in templates: + bailing, bailing-think, bailing2, chatglm3, chatglm4, chatml, + command-r, deepseek, deepseek-ocr, deepseek2, deepseek3, exaone-moe, + exaone3, exaone4, falcon3, gemma, gigachat, glmedge, gpt-oss, granite, + granite-4.0, granite-4.1, grok-2, hunyuan-dense, hunyuan-moe, + hunyuan-vl, kimi-k2, llama2, llama2-sys, llama2-sys-bos, + llama2-sys-strip, llama3, llama4, megrez, minicpm, mistral-v1, + mistral-v3, mistral-v3-tekken, mistral-v7, mistral-v7-tekken, monarch, + openchat, orion, pangu-embedded, phi3, phi4, rwkv-world, seed_oss, + smolvlm, solar-open, vicuna, vicuna-orca, yandex, zephyr + (env: LLAMA_ARG_CHAT_TEMPLATE) +--chat-template-file JINJA_TEMPLATE_FILE + set custom jinja chat template file (default: template taken from + model's metadata) + if suffix/prefix are specified, template will be disabled + only commonly used templates are accepted (unless --jinja is set + before this flag): + list of built-in templates: + bailing, bailing-think, bailing2, chatglm3, chatglm4, chatml, + command-r, deepseek, deepseek-ocr, deepseek2, deepseek3, exaone-moe, + exaone3, exaone4, falcon3, gemma, gigachat, glmedge, gpt-oss, granite, + granite-4.0, granite-4.1, grok-2, hunyuan-dense, hunyuan-moe, + hunyuan-vl, kimi-k2, llama2, llama2-sys, llama2-sys-bos, + llama2-sys-strip, llama3, llama4, megrez, minicpm, mistral-v1, + mistral-v3, mistral-v3-tekken, mistral-v7, mistral-v7-tekken, monarch, + openchat, orion, pangu-embedded, phi3, phi4, rwkv-world, seed_oss, + smolvlm, solar-open, vicuna, vicuna-orca, yandex, zephyr + (env: LLAMA_ARG_CHAT_TEMPLATE_FILE) +--skip-chat-parsing, --no-skip-chat-parsing + force a pure content parser, even if a Jinja template is specified; + model will output everything in the content section, including any + reasoning and/or tool calls (default: disabled) + (env: LLAMA_ARG_SKIP_CHAT_PARSING) +--prefill-assistant, --no-prefill-assistant + whether to prefill the assistant's response if the last message is an + assistant message (default: prefill enabled) + when this flag is set, if the last message is an assistant message + then it will be treated as a full message and not prefilled + + (env: LLAMA_ARG_PREFILL_ASSISTANT) +-sps, --slot-prompt-similarity SIMILARITY + how much the prompt of a request must match the prompt of a slot in + order to use that slot (default: 0.10, 0.0 = disabled) +--lora-init-without-apply load LoRA adapters without applying them (apply later via POST + /lora-adapters) (default: disabled) +--sleep-idle-seconds SECONDS number of seconds of idleness after which the server will sleep + (default: -1; -1 = disabled) +--log-prompts-dir PATH Log prompts to directory (auto-created if not present; only used for + debugging, default: disabled) +--embd-gemma-default use default EmbeddingGemma model (note: can download weights from the + internet) +--fim-qwen-1.5b-default use default Qwen 2.5 Coder 1.5B (note: can download weights from the + internet) +--fim-qwen-3b-default use default Qwen 2.5 Coder 3B (note: can download weights from the + internet) +--fim-qwen-7b-default use default Qwen 2.5 Coder 7B (note: can download weights from the + internet) +--fim-qwen-7b-spec use Qwen 2.5 Coder 7B + 0.5B draft for speculative decoding (note: can + download weights from the internet) +--fim-qwen-14b-spec use Qwen 2.5 Coder 14B + 0.5B draft for speculative decoding (note: + can download weights from the internet) +--fim-qwen-30b-default use default Qwen 3 Coder 30B A3B Instruct (note: can download weights + from the internet) +--gpt-oss-20b-default use gpt-oss-20b (note: can download weights from the internet) +--gpt-oss-120b-default use gpt-oss-120b (note: can download weights from the internet) +--vision-gemma-4b-default use Gemma 3 4B QAT (note: can download weights from the internet) +--vision-gemma-12b-default use Gemma 3 12B QAT (note: can download weights from the internet) +--spec-default enable default speculative decoding config diff --git a/tests/fixtures/canary/help/ovms-2026.4.1-help.txt b/tests/fixtures/canary/help/ovms-2026.4.1-help.txt new file mode 100644 index 00000000..db09e1e4 --- /dev/null +++ b/tests/fixtures/canary/help/ovms-2026.4.1-help.txt @@ -0,0 +1,404 @@ +OpenVINO Model Server +Usage: + /ovms/bin/ovms [OPTION...] + + -h, --help Show this help message and exit + --version Show binary version + --port PORT gRPC server port (default: 0) + --grpc_bind_address GRPC_BIND_ADDRESS + Network interface address to bind to for + the gRPC API (default: 0.0.0.0) + --rest_port REST_PORT REST server port, the REST server will not + be started if rest_port is blank or set to + 0 (default: 0) + --rest_bind_address REST_BIND_ADDRESS + Network interface address to bind to for + the REST API (default: 0.0.0.0) + --grpc_workers GRPC_WORKERS + Number of gRPC servers. Default 1. Increase + for multi client, high throughput scenarios + (default: 1) + --grpc_max_threads GRPC_MAX_THREADS + Maximum number of threads which can be used + by the gRPC server. Default value depends + on number of CPUs. + --grpc_memory_quota GRPC_MEMORY_QUOTA + GRPC server buffer memory quota. Default + value set to 2147483648 (2GB). + --rest_workers REST_WORKERS + Number of worker threads in REST server - + has no effect if rest_port is not set. + Default value depends on number of CPUs. + --log_level LOG_LEVEL serving log level - one of TRACE, DEBUG, + INFO, WARNING, ERROR (default: INFO) + --log_path LOG_PATH Optional path to the log file + --verbose_response When enabled, responses include an extra + "__verbose" object with additional debug + information. + --disable_input_count_validation + When enabled, OVMS allows inference + requests to include additional, + unrecognized inputs beyond the + model/pipeline signature (extra inputs are + ignored). Required inputs must still be + present, and shape/precision validation is + still performed for recognized inputs. + Default: false (extra inputs cause the + request to be rejected). + --grpc_channel_arguments GRPC_CHANNEL_ARGUMENTS + A comma separated list of arguments to be + passed to the gRPC server. (e.g. + grpc.max_connection_age_ms=2000) + --file_system_poll_wait_seconds FILE_SYSTEM_POLL_WAIT_SECONDS + Time interval between config and model + versions changes detection. Default is 1. + Zero or negative value disables changes + monitoring. (default: 1) + --idle_unload_timeout_seconds IDLE_UNLOAD_TIMEOUT_SECONDS + Idle timeout in seconds for model group + unloading. When > 0, models not in the + 'permanent' group are loaded on demand and + unloaded after this idle period. Only + effective with config.json multi-model + setup. Default is 0 (disabled). (default: + 0) + --custom_node_resources_cleaner_interval_seconds CUSTOM_NODE_RESOURCES_CLEANER_INTERVAL_SECONDS + Time interval between two consecutive + resources cleanup scans. Default is 300. + Zero value disables resources cleaner. + (default: 300) + --cache_dir CACHE_DIR Overrides model cache directory. By default + cache files are saved into/opt/cache if the + directory is present. When enabled, first + model load will produce cache files. + --metrics_enable Flag enabling metrics endpoint on + rest_port. + --metrics_list METRICS_LIST + Comma separated list of metrics. If unset, + only default metrics will be enabled. + Default metrics: ovms_requests_success, + ovms_requests_fail, ovms_request_time_us, + ovms_streams, ovms_inference_time_us, + ovms_wait_for_infer_req_time_us. When set, + only the listed metrics will be enabled. + Optional metrics: + ovms_infer_req_queue_size, + ovms_infer_req_active. (default: "") + --cpu_extension CPU_EXTENSION + A path to shared library containing custom + CPU layer implementation. Default: empty. + (default: "") + --allowed_media_domains ALLOWED_MEDIA_DOMAINS + Comma separated list of media domains from + which URLs can be used as input for LLMs. + Set to "all" to disable this restriction. + --allowed_local_media_path ALLOWED_LOCAL_MEDIA_PATH + Path to directory that contains multimedia + files that can be used as input for LLMs. + --allow_credentials Flag enabling credentials on the API. + --allowed_origins ALLOWED_ORIGINS + Comma separated list of origins that are + allowed to access the API. Default: *. + (default: *) + --allowed_methods ALLOWED_METHODS + Comma separated list of methods that are + allowed to access the API. Default: *. + (default: *) + --allowed_headers ALLOWED_HEADERS + Comma separated list of headers that are + allowed to access the API. Default: *. + (default: *) + --api_key_file API_KEY path to the text file containing API key + for authentication for generative + endpoints. If not set, authentication is + disabled. (default: "") + + multi model options: + --config_path CONFIG_PATH + Absolute path to json configuration file + + single model options: + --model_name MODEL_NAME Name of the model + --model_path MODEL_PATH Folder with AI model versions or with + mediapipe graph + --batch_size BATCH_SIZE Resets models batchsize, int value or auto. + This parameter will be ignored if shape is + set + --shape SHAPE Resets models shape (model must support + reshaping). If set, batch_size parameter is + ignored + --layout LAYOUT Resets model layout. It should be in format + <TARGET_LAYOUT>:<SOURCE_LAYOUT> e.g. + NCHW:NHWC + --mean MEAN Resets model mean. + --scale SCALE Resets model scale. + --color_format COLOR_FORMAT + Resets model color format. It should be in + format + <TARGET_COLOR_FORMAT>:<SOURCE_COLOR_FORMAT> + e.g. BGR:RGB + --precision PRECISION Resets model precision. + --model_version_policy MODEL_VERSION_POLICY + Model version policy + --nireq NIREQ Size of inference request queue for model + executions. Recommended to be >= parallel + executions. Default value calculated by + OpenVINO based on available resources. + Request for 0 is treated as request for + default value + --target_device TARGET_DEVICE + Target device to run the inference. + Default: auto-detected based on available + devices. (default: "") + --plugin_config PLUGIN_CONFIG + A dictionary of plugin configuration keys + and their values, eg "{\"NUM_STREAMS\": + \"1\"}". Default number of streams is + optimized to optimal latency with low + concurrency. + + pull hf model options: + --pull Pull model from HF. Uses optional + environment variables: HF_TOKEN - when set + used for authentication, HF_ENDPOINT - when + set replaces huggingface.co for model + download. + --source_model HF_SOURCE HF source model path + --source_loras SOURCE_LORAS + LoRA adapters for image generation. Format: + alias1=org1/repo1,alias2=org2/repo2@file.saf + etensors,alias3=https://url/file.safetensors + ,alias4=/local/path/file.safetensors + --gguf_filename GGUF_FILENAME + Name of the GGUF file + --overwrite_models Overwrite the model if it already exists in + the models repository + --model_repository_path MODEL_REPOSITORY_PATH + HF model destination download path + (default: "") + --weight-format WEIGHT_FORMAT + Model precision used in optimum-cli export + with conversion (default: int8) + --extra_quantization_params EXTRA_QUANTIZATION_PARAMS + Model quantization parameters used in + optimum-cli export with conversion for text + generation models + + generative task (applies to: pull hf model, configure, single model) options: + --task TASK Specifies the generative task for the local model. It + should be followed by task specific parameters. + Supported tasks: text_generation, embeddings, rerank, + image_generation, text2speech, speech2text. It creates + the pipeline graph in memory based on the provided + task-specific options. If not provided, default task + value is inferred from model config. + --configure Create or update graph.pbtxt for the model specified by + --model_path and --task. Does not start the server. + +configure mode (create or update graph.pbtxt for a local model): + ovms --configure --model_path <MODEL_PATH> --task <TASK> [TASK OPTIONS ...] + +config management commands: +Usage: + ovms --add_to_config --config_path <CONFIG_PATH> --model_name <MODEL_NAME> --model_repository_path <MODEL_REPO_PATH> + ovms --add_to_config --config_path <CONFIG_PATH> --model_path <MODEL_PATH> --model_name <MODEL_NAME> --group_name <GROUP> + ovms --remove_from_config --config_path <CONFIG_PATH> --model_name <MODEL_NAME> + + config management options: + --list_models Directive to show available servables in + models repository + --add_to_config Directive to add a model to configuration + file. This parameter should be executed + with --model_name, --config_path and either + with --model_path or + --model_repository_path. + --remove_from_config Directive to remove a model from + configuration file. This parameter should + be executed with --config_path and + --model_name to specify which model to + remove. + --model_repository_path MODEL_REPOSITORY_PATH + Absolute or relative path from the config + directory to the model repository (default: + "") + --model_path MODEL_PATH Absolute or relative path from the config + directory to the model. By default is a + combination of the model_repository_path + and model_name. + --model_name MODEL_NAME Name of the model + --group_name GROUP_NAME Optional group name for idle model group + management + --config_path CONFIG_PATH + Path to json configuration file (default: + "") + +--task text_generation options +Usage: + ovms --pull --task text_generation [OPTIONS...] + ovms --configure --model_path <MODEL_PATH> --task text_generation [OPTIONS...] [OPTION...] + + text generation options: + --max_num_seqs MAX_NUM_SEQS + The maximum number of sequences that can be + processed together. Default 256. (default: + 256) + --pipeline_type PIPELINE_TYPE + Type of the pipeline to be used: Choices + LM, LM_CB, VLM, VLM_CB, AUTO. AUTO is used + by default. + --enable_prefix_caching ENABLE_PREFIX_CACHING + This algorithm is used to cache the prompt + tokens. (default: true) + --max_num_batched_tokens MAX_NUM_BATCHED_TOKENS + empty or integer. The maximum number of + tokens that can be batched together. + --cache_size CACHE_SIZE KV cache size in GB, default is 0 which + mean dynamic allocation. (default: 0) + --draft_source_model DRAFT_SOURCE_MODEL + HF model name or path to the local folder + with PyTorch or OpenVINO draft model. + --draft_model_path DRAFT_MODEL_PATH + Absolute path to an already-exported + OpenVINO draft model directory (use instead + of --draft_source_model for local models). + --draft_device DRAFT_DEVICE + Device to run the draft model on. Defaults + to the same device as the main model. + --draft_eagle3_mode [Deprecated] Draft model strategy is now + auto-detected from model artifacts; this + flag is accepted but ignored. + --dynamic_split_fuse DYNAMIC_SPLIT_FUSE + Dynamic split fuse algorithm enabled. + Default true. (default: true) + --reasoning_parser REASONING_PARSER + Reasoning parser + --tool_parser TOOL_PARSER + Tool parser + --enable_tool_guided_generation ENABLE_TOOL_GUIDED_GENERATION + Enables enforcing tool schema during + generation. Requires setting tool parser. + Default: false. (default: false) + --cache_interval_multiplier CACHE_INTERVAL_MULTIPLIER + Multiplier for the KV cache block interval. + Controls the granularity of cache + allocation. Default: unset. + + plugin config options: + --max_prompt_len MAX_PROMPT_LEN + Sets NPU specific property for maximum + number of tokens in the prompt. + --kv_cache_precision KV_CACHE_PRECISION + u8 or empty (model default). Reduced kv + cache precision to u8 lowers the cache size + consumption. (default: "") + --model_distribution_policy MODEL_DISTRIBUTION_POLICY + TENSOR_PARALLEL, PIPELINE_PARALLEL or empty + (model default). Sets model distribution + policy for inference with multiple + sockets/devices. + +--task rerank options +Usage: + ovms --pull --task rerank [OPTIONS...] + ovms --configure --model_path <MODEL_PATH> --task rerank [OPTIONS...] [OPTION...] + + rerank options: + --num_streams NUM_STREAMS + The number of parallel execution streams to + use for the model. Use at least 2 on 2 + socket CPU systems. (default: 1) + --max_allowed_chunks MAX_ALLOWED_CHUNKS + Maximum allowed chunks. (default: 10000) + +--task embeddings options +Usage: + ovms --pull --task embeddings [OPTIONS...] + ovms --configure --model_path <MODEL_PATH> --task embeddings [OPTIONS...] [OPTION...] + + embeddings options: + --num_streams NUM_STREAMS + The number of parallel execution streams to + use for the model. Use at least 2 on 2 + socket CPU systems. (default: 1) + --normalize NORMALIZE Normalize the embeddings. (default: true) + --truncate truncate Truncate input when it exceeds model + context length. (default: false) + --pooling POOLING Pooling option. One of: CLS, LAST, MEAN. If + omitted, OVMS will detect pooling + automatically. + --max_length MAX_LENGTH Maximum input length in tokens. If omitted, + OVMS will detect it from the model's + config.json. + +--task image_generation options +Usage: + ovms --pull --task image_generation [OPTIONS...] + ovms --configure --model_path <MODEL_PATH> --task image_generation [OPTIONS...] [OPTION...] + + image_generation options: + --resolution RESOLUTION Allowed resolutions in a format list of + WxH; W=width H=height - space separated. If + not specified, inherited from model. If one + is specified, the pipeline will be reshaped + to static. + --max_resolution MAX_RESOLUTION + Max allowed resolution in a format of WxH; + W=width H=height. If not specified, + inherited from model. + --default_resolution DEFAULT_RESOLUTION + Default resolution when not specified by + client in a format of WxH; W=width + H=height. If not specified, inherited from + model. + --num_images_per_prompt NUM_IMAGES_PER_PROMPT + Number of images client is allowed to + request. Can only be used when resolution + parameter is specified and static. By + default, inherited from GenAI (1). + --guidance_scale GUIDANCE_SCALE + Guidance scale used for static pipeline + reshape. Can only be used when resolution + parameter is specified and static. By + default, inherited from GenAI (7.5). + --max_num_images_per_prompt MAX_NUM_IMAGES_PER_PROMPT + Max allowed number of images client is + allowed to request for a given prompt. + --default_num_inference_steps DEFAULT_NUM_INFERENCE_STEPS + Default number of inference steps when not + specified by client. + --max_num_inference_steps MAX_NUM_INFERENCE_STEPS + Max allowed number of inference steps + client is allowed to request for a given + prompt. + --num_streams NUM_STREAMS + The number of parallel execution streams to + use for the image generation models. Use at + least 2 on 2 socket CPU systems. + +--task text2speech options +Usage: + ovms --pull --task text2speech [OPTIONS...] + ovms --configure --model_path <MODEL_PATH> --task text2speech [OPTIONS...] [OPTION...] + + TextToSpeech options: + --num_streams NUM_STREAMS + The number of parallel execution streams to + use for the model. Use at least 2 on 2 + socket CPU systems. (default: 1) + --model_type MODEL_TYPE Type of the source TTS model: speecht5 + (default) or kokoro. (default: speecht5) + --vocoder VOCODER The vocoder model to use for text2speech. + For example microsoft/speecht5_hifigan, + used only with export via optimum-cli. + +--task speech2text options +Usage: + ovms --pull --task speech2text [OPTIONS...] + ovms --configure --model_path <MODEL_PATH> --task speech2text [OPTIONS...] [OPTION...] + + SpeechToText options: + --num_streams NUM_STREAMS + The number of parallel execution streams to + use for the model. Use at least 2 on 2 + socket CPU systems. (default: 1) + diff --git a/tests/fixtures/canary/help/sglang-0.5.22-xeon-help.txt b/tests/fixtures/canary/help/sglang-0.5.22-xeon-help.txt new file mode 100644 index 00000000..fe2f7071 --- /dev/null +++ b/tests/fixtures/canary/help/sglang-0.5.22-xeon-help.txt @@ -0,0 +1,2686 @@ +/opt/.venv/lib/python3.12/site-packages/sglang/launch_server.py:71: UserWarning: 'python -m sglang.launch_server' is still supported, but 'sglang serve' is the recommended entrypoint. + Example: sglang serve --model-path <model> [options] + warnings.warn( +usage: sglang serve [-h] --model-path MODEL_PATH + [--tokenizer-path TOKENIZER_PATH] + [--tokenizer-mode {auto,slow}] + [--tokenizer-backend {huggingface,fastokens}] + [--tokenizer-worker-num TOKENIZER_WORKER_NUM] + [--detokenizer-worker-num DETOKENIZER_WORKER_NUM] + [--skip-tokenizer-init] + [--load-format {auto,pt,safetensors,instanttensor,npcache,dummy,sharded_state,presharded,gguf,expert_pack,bitsandbytes,mistral,layered,flash_rl,remote,remote_instance,fastsafetensors,private,runai_streamer}] + [--model-loader-extra-config MODEL_LOADER_EXTRA_CONFIG] + [--trust-remote-code] [--context-length CONTEXT_LENGTH] + [--is-embedding] [--enable-multimodal] + [--revision REVISION] [--model-impl MODEL_IMPL] + [--model-config-parser MODEL_CONFIG_PARSER] + [--json-model-override-args JSON_MODEL_OVERRIDE_ARGS] + [--dtype {auto,half,float16,bfloat16,float,float32}] + [--quantization {awq,fp8,mxfp8,gptq,gptq_marlin,awq_marlin,bitsandbytes,gguf,modelopt,modelopt_fp8,modelopt_fp4,nvfp4_online,modelopt_mixed,petit_nvfp4,w8a8_int8,w8a8_fp8,moe_wna16,w4afp8,mxfp4,auto-round,auto-round-int8,compressed-tensors,modelslim,mxfp_w4a8,quark,quark_int4fp8_moe,quark_mxfp4,mlx_q4,mlx_q8,unquant,humming}] + [--quantization-param-path QUANTIZATION_PARAM_PATH] + [--kv-cache-dtype {auto,fp8_e5m2,fp8_e4m3,mxfp8,bf16,bfloat16,nvfp4,fp4_mx_block16}] + [--enable-fp32-lm-head] [--modelopt-quant MODELOPT_QUANT] + [--modelopt-checkpoint-restore-path MODELOPT_CHECKPOINT_RESTORE_PATH] + [--modelopt-checkpoint-save-path MODELOPT_CHECKPOINT_SAVE_PATH] + [--modelopt-export-path MODELOPT_EXPORT_PATH] + [--quantize-and-serve] + [--rl-quant-profile RL_QUANT_PROFILE] + [--enable-tf32-matmul] + [--mem-fraction-static MEM_FRACTION_STATIC] + [--max-running-requests MAX_RUNNING_REQUESTS] + [--max-queued-requests MAX_QUEUED_REQUESTS] + [--max-total-tokens MAX_TOTAL_TOKENS] + [--chunked-prefill-size CHUNKED_PREFILL_SIZE] + [--prefill-decode-interval PREFILL_DECODE_INTERVAL] + [--enable-dynamic-chunking] + [--max-prefill-tokens MAX_PREFILL_TOKENS] + [--prefill-max-requests PREFILL_MAX_REQUESTS] + [--schedule-policy {lpm,random,fcfs,dfs-weight,lof,priority,routing-key,hrrn,shortest-prefill-first}] + [--enable-priority-scheduling] + [--disable-priority-preemption] + [--default-priority-value DEFAULT_PRIORITY_VALUE] + [--abort-on-priority-when-disabled] + [--schedule-low-priority-values-first] + [--priority-scheduling-preemption-threshold PRIORITY_SCHEDULING_PREEMPTION_THRESHOLD] + [--retraction-policy {length,priority}] + [--schedule-conservativeness SCHEDULE_CONSERVATIVENESS] + [--page-size PAGE_SIZE] [--c128-page-size C128_PAGE_SIZE] + [--swa-full-tokens-ratio SWA_FULL_TOKENS_RATIO] + [--disable-hybrid-swa-memory] + [--radix-eviction-policy {lru,lfu,slru,priority,tlru}] + [--radix-eviction-policy-config RADIX_EVICTION_POLICY_CONFIG] + [--prefill-only-disable-kv-cache] [--disable-radix-cache] + [--enable-page-major-kv-layout] [--enable-unified-memory] + [--disable-chunked-prefix-cache] + [--disable-overlap-schedule] + [--num-continuous-decode-steps NUM_CONTINUOUS_DECODE_STEPS] + [--scheduler-recv-interval SCHEDULER_RECV_INTERVAL] + [--enable-mixed-chunk] [--nccl-port NCCL_PORT] + [--dist-timeout DIST_TIMEOUT] + [--dist-init-addr DIST_INIT_ADDR] + [--gated-launch-port GATED_LAUNCH_PORT] [--nnodes NNODES] + [--node-rank NODE_RANK] [--tp-size TP_SIZE] + [--dcp-size DCP_SIZE] [--pp-size PP_SIZE] + [--pp-max-micro-batch-size PP_MAX_MICRO_BATCH_SIZE] + [--pp-async-batch-depth PP_ASYNC_BATCH_DEPTH] + [--dp-size DP_SIZE] [--attn-dp-size ATTN_DP_SIZE] + [--load-balance-method {auto,round_robin,follow_bootstrap_room,total_requests,total_tokens}] + [--attn-cp-size ATTN_CP_SIZE] [--moe-dp-size MOE_DP_SIZE] + [--dwdp-size DWDP_SIZE] + [--dcp-comm-backend {ag_rs,a2a,fi_a2a}] + [--dcp-replicate-q-proj | --no-dcp-replicate-q-proj] + [--enable-prefill-cp] [--cp-strategy {zigzag,interleave}] + [--enable-dsa-cache-layer-split] + [--enable-cp-decode-attn-tp] [--enable-kv-cache-sharding] + [--enable-dp-attention-local-control-broadcast] + [--enable-dp-lm-head] + [--enable-tp-lm-head-all-to-all | --no-enable-tp-lm-head-all-to-all] + [--enable-attn-tp-input-scattered] + [--enable-shared-experts-attn-tp] + [--shared-experts-tp-size SHARED_EXPERTS_TP_SIZE] + [--enable-dense-mlp-attn-tp] [--enable-layernorm-sp] + [--disable-attn-tp-gather] [--enable-p2p-check] + [--device DEVICE] [--base-gpu-id BASE_GPU_ID] + [--gpu-id-step GPU_ID_STEP] [--random-seed RANDOM_SEED] + [--mlx-enable-sampling] + [--watchdog-timeout WATCHDOG_TIMEOUT] + [--soft-watchdog-timeout SOFT_WATCHDOG_TIMEOUT] + [--sleep-on-idle] [--use-ray] + [--numa-node NUMA_NODE [NUMA_NODE ...]] + [--gc-threshold GC_THRESHOLD [GC_THRESHOLD ...]] + [--host HOST] [--port PORT] + [--fastapi-root-path FASTAPI_ROOT_PATH] [--smg-grpc-mode] + [--grpc-mode] [--grpc-port GRPC_PORT] [--sidecar SIDECAR] + [--sidecar-args SIDECAR_ARGS] [--skip-server-warmup] + [--warmups WARMUPS] [--enable-http2] + [--http2-max-concurrent-streams HTTP2_MAX_CONCURRENT_STREAMS] + [--http2-initial-connection-window-size HTTP2_INITIAL_CONNECTION_WINDOW_SIZE] + [--ssl-keyfile SSL_KEYFILE] [--ssl-certfile SSL_CERTFILE] + [--ssl-ca-certs SSL_CA_CERTS] + [--ssl-keyfile-password SSL_KEYFILE_PASSWORD] + [--enable-ssl-refresh] [--api-key API_KEY] + [--admin-api-key ADMIN_API_KEY] + [--served-model-name SERVED_MODEL_NAME] + [--weight-version WEIGHT_VERSION] + [--chat-template CHAT_TEMPLATE] + [--trust-request-chat-template] + [--hf-chat-template-name HF_CHAT_TEMPLATE_NAME] + [--completion-template COMPLETION_TEMPLATE] + [--file-storage-path FILE_STORAGE_PATH] + [--enable-cache-report] [--return-input-ids] + [--return-output-ids] + [--default-chat-template-kwargs DEFAULT_CHAT_TEMPLATE_KWARGS] + [--strip-thinking-cache] [--enable-strict-thinking] + [--tool-server TOOL_SERVER] + [--sampling-defaults {openai,model}] + [--asr-max-buffer-seconds ASR_MAX_BUFFER_SECONDS] + [--asr-max-concurrent-sessions ASR_MAX_CONCURRENT_SESSIONS] + [--preferred-sampling-params PREFERRED_SAMPLING_PARAMS] + [--allow-auto-truncate] + [--stream-interval STREAM_INTERVAL] + [--batch-notify-size BATCH_NOTIFY_SIZE] + [--stream-response-default-include-usage] + [--incremental-streaming-output] + [--enable-streaming-session] + [--enable-session-radix-cache] [--log-level LOG_LEVEL] + [--log-level-http LOG_LEVEL_HTTP] [--log-requests] + [--log-requests-level {0,1,2,3}] + [--log-requests-format {text,json}] + [--log-requests-target LOG_REQUESTS_TARGET [LOG_REQUESTS_TARGET ...]] + [--uvicorn-access-log-exclude-prefixes [UVICORN_ACCESS_LOG_EXCLUDE_PREFIXES ...]] + [--crash-dump-folder CRASH_DUMP_FOLDER] [--show-time-cost] + [--enable-metrics] + [--smg-http-sidecar-port SMG_HTTP_SIDECAR_PORT] + [--enable-mfu-metrics] + [--enable-metrics-for-all-schedulers] + [--load-snapshot-publish-interval LOAD_SNAPSHOT_PUBLISH_INTERVAL] + [--tokenizer-metrics-custom-labels-header TOKENIZER_METRICS_CUSTOM_LABELS_HEADER] + [--tokenizer-metrics-allowed-custom-labels TOKENIZER_METRICS_ALLOWED_CUSTOM_LABELS [TOKENIZER_METRICS_ALLOWED_CUSTOM_LABELS ...]] + [--extra-metric-labels EXTRA_METRIC_LABELS] + [--bucket-time-to-first-token BUCKET_TIME_TO_FIRST_TOKEN [BUCKET_TIME_TO_FIRST_TOKEN ...]] + [--bucket-inter-token-latency BUCKET_INTER_TOKEN_LATENCY [BUCKET_INTER_TOKEN_LATENCY ...]] + [--bucket-e2e-request-latency BUCKET_E2E_REQUEST_LATENCY [BUCKET_E2E_REQUEST_LATENCY ...]] + [--prompt-tokens-buckets PROMPT_TOKENS_BUCKETS [PROMPT_TOKENS_BUCKETS ...]] + [--generation-tokens-buckets GENERATION_TOKENS_BUCKETS [GENERATION_TOKENS_BUCKETS ...]] + [--gc-warning-threshold-secs GC_WARNING_THRESHOLD_SECS] + [--decode-log-interval DECODE_LOG_INTERVAL] + [--enable-request-time-stats-logging] + [--kv-events-config KV_EVENTS_CONFIG] + [--load-publish-endpoint LOAD_PUBLISH_ENDPOINT] + [--enable-forward-pass-metrics] [--enable-trace] + [--trace-modules TRACE_MODULES] + [--otlp-traces-endpoint OTLP_TRACES_ENDPOINT] + [--export-metrics-to-file] + [--export-metrics-to-file-dir EXPORT_METRICS_TO_FILE_DIR] + [--constrained-json-whitespace-pattern CONSTRAINED_JSON_WHITESPACE_PATTERN] + [--constrained-json-disable-any-whitespace] + [--constrained-json-max-whitespace-cnt CONSTRAINED_JSON_MAX_WHITESPACE_CNT] + [--attention-backend {triton,torch_native,flex_attention,dsa,nsa,qsa,dsv4,compressed,fa3,fa4,flashinfer,llada2_cfg_flashinfer,flashmla,trtllm_mla,cutedsl_mla,tokenspeed_mla,trtllm_mha,hpc_ops,minicpm_flashattn,minicpm_flashinfer,aiter,wave,intel_amx,ascend,intel_xpu}] + [--decode-attention-backend {triton,torch_native,flex_attention,dsa,nsa,qsa,dsv4,compressed,fa3,fa4,flashinfer,llada2_cfg_flashinfer,flashmla,trtllm_mla,cutedsl_mla,tokenspeed_mla,trtllm_mha,hpc_ops,minicpm_flashattn,minicpm_flashinfer,aiter,wave,intel_amx,ascend,intel_xpu}] + [--enable-lean-attention] + [--prefill-attention-backend {triton,torch_native,flex_attention,dsa,nsa,qsa,dsv4,compressed,fa3,fa4,flashinfer,llada2_cfg_flashinfer,flashmla,trtllm_mla,cutedsl_mla,tokenspeed_mla,trtllm_mha,hpc_ops,minicpm_flashattn,minicpm_flashinfer,aiter,wave,intel_amx,ascend,intel_xpu}] + [--grammar-backend {xgrammar,outlines,llguidance,none}] + [--radix-cache-backend RADIX_CACHE_BACKEND] + [--mm-attention-backend {sdpa,fa3,fa4,triton_attn,ascend_attn,aiter_attn,flashinfer_cudnn,amx_attn,xpu_attn}] + [--fp8-gemm-backend {auto,deep_gemm,flashinfer_trtllm,flashinfer_cutlass,flashinfer_deepgemm,flashinfer_cutedsl,cutlass,triton,aiter}] + [--fp4-gemm-backend {auto,flashinfer_cudnn,flashinfer_cutedsl,flashinfer_cutlass,flashinfer_trtllm,marlin}] + [--bf16-gemm-backend {auto,cutedsl,gemv,torch}] + [--dsa-prefill-backend {flashmla_sparse,flashmla_sparse_q8,flashmla_kv,flashmla_auto,flashinfer_sparse_mla,triton_sparse_mla,fa3,tilelang,triton,aiter,trtllm,intel_xpu}] + [--dsv4-prefill-backend {auto,flashmla_sparse,flashmla_sparse_q8}] + [--dsa-decode-backend {flashmla_sparse,flashmla_sparse_q8,flashmla_kv,flashmla_auto,flashinfer_sparse_mla,fa3,tilelang,triton,aiter,trtllm,intel_xpu}] + [--dsa-paged-mqa-logits-backend {auto,deepgemm,cutedsl,aiter}] + [--dsa-topk-backend {sgl-kernel,torch,flashinfer}] + [--disable-flashinfer-autotune] + [--flashinfer-autotune-skip-ops FLASHINFER_AUTOTUNE_SKIP_OPS [FLASHINFER_AUTOTUNE_SKIP_OPS ...]] + [--mamba-backend {triton,flashinfer}] + [--cuda-graph-config CUDA_GRAPH_CONFIG] + [--cuda-graph-backend-decode {full,breakable,disabled}] + [--cuda-graph-backend-prefill {full,breakable,disabled}] + [--cuda-graph-max-bs-decode CUDA_GRAPH_MAX_BS_DECODE] + [--cuda-graph-max-bs-prefill CUDA_GRAPH_MAX_BS_PREFILL] + [--cuda-graph-bs-decode CUDA_GRAPH_BS_DECODE [CUDA_GRAPH_BS_DECODE ...]] + [--cuda-graph-bs-prefill CUDA_GRAPH_BS_PREFILL [CUDA_GRAPH_BS_PREFILL ...]] + [--cuda-graph-prefill-max-context CUDA_GRAPH_PREFILL_MAX_CONTEXT] + [--disable-prefill-cuda-graph] + [--disable-decode-cuda-graph] + [--disable-cuda-graph-padding] + [--enable-profile-cuda-graph] [--enable-cudagraph-gc] + [--debug-cuda-graph] [--enable-layerwise-nvtx-marker] + [--enable-nccl-nvls] [--enable-symm-mem] + [--triton-attention-reduce-in-fp32] + [--triton-attention-num-kv-splits TRITON_ATTENTION_NUM_KV_SPLITS] + [--triton-attention-split-tile-size TRITON_ATTENTION_SPLIT_TILE_SIZE] + [--flashinfer-mla-disable-ragged] + [--enable-fused-qk-norm-rope] + [--enable-precise-embedding-interpolation] + [--enable-fused-moe-sum-all-reduce] + [--enable-deepseek-v4-fp4-indexer] + [--disable-custom-all-reduce] [--enable-mscclpp] + [--enable-torch-symm-mem] [--enable-scattered-sconv] + [--pre-warm-nccl] + [--boundary-reduction {auto,ar,rs,rsv,rs+rsv}] + [--enable-quant-communications] + [--enforce-disable-flashinfer-allreduce-fusion] + [--flashinfer-allreduce-fusion-backend {auto,trtllm,mnnvl,cutedsl}] + [--enable-aiter-allreduce-fusion] [--enable-torch-compile] + [--enable-torch-compile-debug-mode] + [--torch-compile-max-bs TORCH_COMPILE_MAX_BS] + [--speculative-algorithm SPECULATIVE_ALGORITHM] + [--uno-lora-path UNO_LORA_PATH] + [--speculative-draft-model-path SPECULATIVE_DRAFT_MODEL_PATH] + [--speculative-draft-model-revision SPECULATIVE_DRAFT_MODEL_REVISION] + [--speculative-draft-load-format {auto,pt,safetensors,instanttensor,npcache,dummy,sharded_state,presharded,gguf,expert_pack,bitsandbytes,mistral,layered,flash_rl,remote,remote_instance,fastsafetensors,private,runai_streamer}] + [--speculative-num-steps SPECULATIVE_NUM_STEPS] + [--speculative-eagle-topk SPECULATIVE_EAGLE_TOPK] + [--speculative-num-draft-tokens SPECULATIVE_NUM_DRAFT_TOKENS] + [--speculative-dflash-block-size SPECULATIVE_DFLASH_BLOCK_SIZE] + [--speculative-dspark-block-size SPECULATIVE_DSPARK_BLOCK_SIZE] + [--speculative-dspark-sps-table-path SPECULATIVE_DSPARK_SPS_TABLE_PATH] + [--speculative-dspark-confidence-sts-path SPECULATIVE_DSPARK_CONFIDENCE_STS_PATH] + [--speculative-dspark-align-verify-tokens-to-graph-tier] + [--speculative-accept-threshold-single SPECULATIVE_ACCEPT_THRESHOLD_SINGLE] + [--speculative-accept-threshold-acc SPECULATIVE_ACCEPT_THRESHOLD_ACC] + [--speculative-use-rejection-sampling] + [--speculative-token-map SPECULATIVE_TOKEN_MAP] + [--speculative-attention-mode {prefill,decode}] + [--speculative-draft-attention-backend SPECULATIVE_DRAFT_ATTENTION_BACKEND] + [--speculative-dsa-topk-backend {sgl-kernel,torch,flashinfer}] + [--speculative-draft-kv-cache-dtype {auto,fp8_e5m2,fp8_e4m3,bf16,bfloat16}] + [--speculative-draft-window-size SPECULATIVE_DRAFT_WINDOW_SIZE] + [--speculative-moe-runner-backend {auto,deep_gemm,triton,triton_kernel,flashinfer_trtllm,experimental_sgl_trtllm,flashinfer_trtllm_routed,flashinfer_cutlass,flashinfer_mxfp4,flashinfer_cutedsl,flashinfer_megamoe,cutlass,aiter,marlin,humming,experimental_sgl_marlin,hpc_ops,megamoe,intel_xpu}] + [--speculative-moe-a2a-backend {none,deepep,mooncake,nixl,mori,ascend_fuseep,flashinfer,megamoe,deepep_v2,pplx,ascend_tp}] + [--speculative-enable-w4a4-mxfp4-megamoe | --no-speculative-enable-w4a4-mxfp4-megamoe] + [--speculative-draft-model-quantization {awq,fp8,mxfp8,gptq,gptq_marlin,awq_marlin,bitsandbytes,gguf,modelopt,modelopt_fp8,modelopt_fp4,nvfp4_online,modelopt_mixed,petit_nvfp4,w8a8_int8,w8a8_fp8,moe_wna16,w4afp8,mxfp4,auto-round,auto-round-int8,compressed-tensors,modelslim,mxfp_w4a8,quark,quark_int4fp8_moe,quark_mxfp4,mlx_q4,mlx_q8,unquant,humming}] + [--speculative-skip-dp-mlp-sync] + [--enable-multi-layer-eagle] [--speculative-adaptive] + [--speculative-adaptive-config SPECULATIVE_ADAPTIVE_CONFIG] + [--decoupled-spec-bind-endpoint DECOUPLED_SPEC_BIND_ENDPOINT] + [--decoupled-spec-connect-endpoints DECOUPLED_SPEC_CONNECT_ENDPOINTS] + [--decoupled-spec-rank DECOUPLED_SPEC_RANK] + [--decoupled-spec-role {null,verifier,drafter}] + [--spec-trace-dir SPEC_TRACE_DIR] + [--speculative-ngram-min-bfs-breadth SPECULATIVE_NGRAM_MIN_BFS_BREADTH] + [--speculative-ngram-max-bfs-breadth SPECULATIVE_NGRAM_MAX_BFS_BREADTH] + [--speculative-ngram-match-type {BFS,PROB}] + [--speculative-ngram-max-trie-depth SPECULATIVE_NGRAM_MAX_TRIE_DEPTH] + [--speculative-ngram-capacity SPECULATIVE_NGRAM_CAPACITY] + [--speculative-ngram-external-corpus-path SPECULATIVE_NGRAM_EXTERNAL_CORPUS_PATH] + [--speculative-ngram-external-sam-budget SPECULATIVE_NGRAM_EXTERNAL_SAM_BUDGET] + [--speculative-ngram-external-corpus-max-tokens SPECULATIVE_NGRAM_EXTERNAL_CORPUS_MAX_TOKENS] + [--ep-size EP_SIZE] + [--moe-a2a-backend {none,deepep,mooncake,nixl,mori,ascend_fuseep,flashinfer,megamoe,deepep_v2,pplx,ascend_tp,flashinfer_megamoe}] + [--enable-w4a4-mxfp4-megamoe] + [--deepep-v2-mode {direct,hybrid}] + [--moe-runner-backend {auto,deep_gemm,triton,triton_kernel,flashinfer_trtllm,experimental_sgl_trtllm,flashinfer_trtllm_routed,flashinfer_cutlass,flashinfer_mxfp4,flashinfer_cutedsl,flashinfer_megamoe,cutlass,aiter,marlin,humming,experimental_sgl_marlin,hpc_ops,megamoe,intel_xpu}] + [--flashinfer-mxfp4-moe-precision {default,bf16,fp8}] + [--deepep-mode {auto,normal,low_latency}] + [--fuseep-mode {1,2}] + [--deepep-dispatcher-output-dtype {auto,bf16,fp8,int8,nvfp4}] + [--ep-num-redundant-experts EP_NUM_REDUNDANT_EXPERTS] + [--ep-dispatch-algorithm {static,dynamic,fake,lp}] + [--init-expert-location INIT_EXPERT_LOCATION] + [--enable-eplb] [--eplb-algorithm EPLB_ALGORITHM] + [--eplb-rebalance-num-iterations EPLB_REBALANCE_NUM_ITERATIONS] + [--eplb-rebalance-layers-per-chunk EPLB_REBALANCE_LAYERS_PER_CHUNK] + [--eplb-min-rebalancing-utilization-threshold EPLB_MIN_REBALANCING_UTILIZATION_THRESHOLD] + [--expert-distribution-recorder-mode {stat,stat_approx,per_pass,per_token}] + [--expert-distribution-recorder-buffer-size EXPERT_DISTRIBUTION_RECORDER_BUFFER_SIZE] + [--expert-balancedness-report-mode {off,server_log,prometheus,both}] + [--deepep-config DEEPEP_CONFIG] + [--moe-dense-tp-size MOE_DENSE_TP_SIZE] + [--elastic-ep-backend {none,mooncake,nixl}] + [--enable-elastic-expert-backup] + [--mooncake-ib-device MOONCAKE_IB_DEVICE] + [--enable-waterfill] + [--elastic-ep-join-mode {scale,recover}] + [--elastic-ep-join-rank-offset EP_JOIN_RANK_OFFSET] + [--elastic-ep-initial-size ELASTIC_EP_INITIAL_SIZE] + [--max-ep-size MAX_EP_SIZE] + [--elastic-ep-scale-timeout ELASTIC_EP_SCALE_TIMEOUT] + [--disable-flashinfer-cutlass-moe-fp4-allgather] + [--disable-shared-experts-fusion] + [--enforce-shared-experts-fusion] + [--max-mamba-cache-size MAX_MAMBA_CACHE_SIZE] + [--mamba-ssm-dtype {float32,bfloat16,float16}] + [--mamba-max-states-per-path MAMBA_MAX_STATES_PER_PATH] + [--enable-mamba-cache-stochastic-rounding] + [--mamba-cache-philox-rounds MAMBA_CACHE_PHILOX_ROUNDS] + [--mamba-full-memory-ratio MAMBA_FULL_MEMORY_RATIO] + [--mamba-radix-cache-strategy {auto,no_buffer,extra_buffer,extra_buffer_lazy}] + [--mamba-track-interval MAMBA_TRACK_INTERVAL] + [--enable-int8-mamba-checkpoint] + [--int8-mamba-ckpt-size INT8_MAMBA_CKPT_SIZE] + [--linear-attn-backend {triton,cutedsl,flashinfer,flashkda,nvidia_kda,ptx_kda,helion,intel_xpu}] + [--linear-attn-decode-backend {triton,cutedsl,flashinfer,flashkda,nvidia_kda,ptx_kda,helion,intel_xpu}] + [--linear-attn-prefill-backend {triton,cutedsl,flashinfer,flashkda,nvidia_kda,ptx_kda,helion,intel_xpu}] + [--linear-attn-verify-backend {triton,cutedsl,flashinfer,flashkda,nvidia_kda,ptx_kda,helion,intel_xpu,nv_cutedsl}] + [--enable-linear-replayssm] + [--linear-replayssm-cache-len LINEAR_REPLAYSSM_CACHE_LEN] + [--enable-linear-replayssm-spec] + [--enable-hierarchical-cache] + [--hicache-host-memory-mode {cache,buffer_only}] + [--hicache-ratio HICACHE_RATIO] + [--hicache-size HICACHE_SIZE] + [--hicache-write-policy {write_back,write_through,write_through_selective}] + [--hicache-io-backend {direct,kernel,kernel_ascend}] + [--hicache-mem-layout {layer_first,page_first,page_first_direct,page_first_kv_split,page_head}] + [--hicache-storage-backend {file,sim,mooncake,npu_memcache,hf3fs,nixl,aibrix,dynamic,eic,simm,mori,shm,tensorcast,seaweedfs}] + [--hicache-storage-prefetch-policy {best_effort,wait_complete,timeout}] + [--hicache-storage-backend-extra-config HICACHE_STORAGE_BACKEND_EXTRA_CONFIG] + [--hicache-storage-prefetch-retry-poll-interval HICACHE_STORAGE_PREFETCH_RETRY_POLL_INTERVAL] + [--hicache-storage-prefetch-retry-max-attempts HICACHE_STORAGE_PREFETCH_RETRY_MAX_ATTEMPTS] + [--enable-unified-cache-external-linker] + [--unified-cache-external-linker-backend {mooncake,mori}] + [--enable-hisparse] [--hisparse-config HISPARSE_CONFIG] + [--enable-broadcast-mm-inputs-process] + [--enable-prefix-mm-cache] [--mm-enable-dp-encoder] + [--mm-process-config MM_PROCESS_CONFIG] + [--mm-processor-worker-num MM_PROCESSOR_WORKER_NUM] + [--mm-io-worker-num MM_IO_WORKER_NUM] + [--allowed-media-domains ALLOWED_MEDIA_DOMAINS [ALLOWED_MEDIA_DOMAINS ...]] + [--media-url-max-file-size-mb MEDIA_URL_MAX_FILE_SIZE_MB] + [--mm-preprocess-cache-size-mb MM_PREPROCESS_CACHE_SIZE_MB] + [--trust-mm-content-hashes] + [--limit-mm-data-per-request LIMIT_MM_DATA_PER_REQUEST] + [--enable-mm-global-cache] + [--image-processor-backend {auto,torchvision,pil}] + [--mm-global-cache-backend {mooncake}] + [--disable-fast-image-processor] + [--mm-feature-transport {cpu,cuda_ipc,cuda_vmm}] + [--enable-lora] [--enable-lora-overlap-loading] + [--max-lora-rank MAX_LORA_RANK] + [--lora-target-modules [{q_proj,k_proj,v_proj,o_proj,q_a_proj,kv_a_proj_with_mqa,q_b_proj,kv_b_proj,wq_b,wk,weights_proj,gate_proj,up_proj,down_proj,qkv_proj,gate_up_proj,embed_tokens,lm_head,qkvr,wo_ud,all} ...]] + [--lora-paths [LORA_PATHS ...]] + [--max-loaded-loras MAX_LOADED_LORAS] + [--max-loras-per-batch MAX_LORAS_PER_BATCH] + [--lora-eviction-policy {lru,fifo}] + [--lora-backend {triton,csgmv,ascend,torch_native}] + [--max-lora-chunk-size {16,32,64,128}] + [--experts-shared-outer-loras | --no-experts-shared-outer-loras] + [--lora-use-virtual-experts] + [--lora-strict-loading | --no-lora-strict-loading] + [--lora-drain-wait-threshold LORA_DRAIN_WAIT_THRESHOLD] + [--enable-two-batch-overlap] + [--enable-single-batch-overlap] + [--tbo-token-distribution-threshold TBO_TOKEN_DISTRIBUTION_THRESHOLD] + [--cpu-offload-gb CPU_OFFLOAD_GB] + [--offload-group-size OFFLOAD_GROUP_SIZE] + [--offload-num-in-group OFFLOAD_NUM_IN_GROUP] + [--offload-prefetch-step OFFLOAD_PREFETCH_STEP] + [--offload-mode OFFLOAD_MODE] [--enable-lmcache] + [--lmcache-config-file LMCACHE_CONFIG_FILE] + [--enable-flexkv] + [--flexkv-config-file FLEXKV_CONFIG_FILE] + [--kt-weight-path KT_WEIGHT_PATH] [--kt-method KT_METHOD] + [--kt-cpuinfer KT_CPUINFER] + [--kt-threadpool-count KT_THREADPOOL_COUNT] + [--kt-num-gpu-experts KT_NUM_GPU_EXPERTS] + [--kt-max-deferred-experts-per-token KT_MAX_DEFERRED_EXPERTS_PER_TOKEN] + [--dllm-algorithm DLLM_ALGORITHM] + [--dllm-algorithm-config DLLM_ALGORITHM_CONFIG] + [--dllm-fdfo | --no-dllm-fdfo] + [--disaggregation-mode {null,prefill,decode}] + [--disaggregation-transfer-backend {mooncake,nixl,ascend,fake,mori,mooncake_tcp}] + [--disaggregation-bootstrap-port DISAGGREGATION_BOOTSTRAP_PORT] + [--disaggregation-ib-device DISAGGREGATION_IB_DEVICE] + [--disaggregation-decode-enable-radix-cache] + [--disaggregation-decode-enable-offload-kvcache] + [--disaggregation-decode-retraction-backup {cpu_tensor,host_pool,none}] + [--num-reserved-decode-tokens NUM_RESERVED_DECODE_TOKENS] + [--disaggregation-decode-extra-slots DISAGGREGATION_DECODE_EXTRA_SLOTS] + [--disaggregation-decode-polling-interval DISAGGREGATION_DECODE_POLLING_INTERVAL] + [--optimistic-prefill-attempts OPTIMISTIC_PREFILL_ATTEMPTS] + [--encoder-only] [--language-only] [--language-model-only] + [--encoder-transfer-backend {auto,zmq_to_scheduler,zmq_to_tokenizer,mooncake}] + [--encoder-urls ENCODER_URLS [ENCODER_URLS ...]] + [--encoder-bootstrap-port ENCODER_BOOTSTRAP_PORT] + [--encoder-register-urls ENCODER_REGISTER_URLS [ENCODER_REGISTER_URLS ...]] + [--enable-adaptive-dispatch-to-encoder] [--enable-pdmux] + [--pdmux-config-path PDMUX_CONFIG_PATH] + [--sm-group-num SM_GROUP_NUM] + [--startup-weight-load-mode {serial,overlap}] + [--custom-weight-loader [CUSTOM_WEIGHT_LOADER ...]] + [--weight-loader-disable-mmap] + [--weight-loader-prefetch-checkpoints] + [--weight-loader-prefetch-num-threads WEIGHT_LOADER_PREFETCH_NUM_THREADS] + [--weight-loader-drop-cache-after-load] + [--remote-instance-weight-loader-seed-instance-ip REMOTE_INSTANCE_WEIGHT_LOADER_SEED_INSTANCE_IP] + [--remote-instance-weight-loader-seed-instance-service-port REMOTE_INSTANCE_WEIGHT_LOADER_SEED_INSTANCE_SERVICE_PORT] + [--remote-instance-weight-loader-send-weights-group-ports REMOTE_INSTANCE_WEIGHT_LOADER_SEND_WEIGHTS_GROUP_PORTS] + [--remote-instance-weight-loader-backend {transfer_engine,nccl,modelexpress}] + [--remote-instance-weight-loader-start-seed-via-transfer-engine] + [--engine-info-bootstrap-port ENGINE_INFO_BOOTSTRAP_PORT] + [--modelexpress-config MODELEXPRESS_CONFIG] + [--download-dir DOWNLOAD_DIR] + [--model-checksum [MODEL_CHECKSUM]] + [--delete-ckpt-after-loading] + [--decrypted-config-file DECRYPTED_CONFIG_FILE] + [--decrypted-draft-config-file DECRYPTED_DRAFT_CONFIG_FILE] + [--checkpoint-engine-wait-weights-before-ready] + [--enable-prefill-delayer] + [--prefill-delayer-max-delay-passes PREFILL_DELAYER_MAX_DELAY_PASSES] + [--prefill-delayer-token-usage-low-watermark PREFILL_DELAYER_TOKEN_USAGE_LOW_WATERMARK] + [--prefill-delayer-forward-passes-buckets PREFILL_DELAYER_FORWARD_PASSES_BUCKETS [PREFILL_DELAYER_FORWARD_PASSES_BUCKETS ...]] + [--prefill-delayer-wait-seconds-buckets PREFILL_DELAYER_WAIT_SECONDS_BUCKETS [PREFILL_DELAYER_WAIT_SECONDS_BUCKETS ...]] + [--prefill-delayer-queue-min-ratio PREFILL_DELAYER_QUEUE_MIN_RATIO] + [--prefill-delayer-max-delay-ms PREFILL_DELAYER_MAX_DELAY_MS] + [--min-free-slots-delay MIN_FREE_SLOTS_DELAY] + [--enable-deterministic-inference] + [--rl-on-policy-target {fsdp}] + [--kv-canary {none,log,raise}] + [--kv-canary-sweep-interval KV_CANARY_SWEEP_INTERVAL] + [--enable-dynamic-batch-tokenizer] + [--dynamic-batch-tokenizer-batch-size DYNAMIC_BATCH_TOKENIZER_BATCH_SIZE] + [--dynamic-batch-tokenizer-batch-timeout DYNAMIC_BATCH_TOKENIZER_BATCH_TIMEOUT] + [--enable-tokenizer-batch-encode] + [--disable-tokenizer-batch-decode] + [--debug-tensor-dump-output-folder DEBUG_TENSOR_DUMP_OUTPUT_FOLDER] + [--debug-tensor-dump-layers DEBUG_TENSOR_DUMP_LAYERS [DEBUG_TENSOR_DUMP_LAYERS ...]] + [--debug-tensor-dump-input-file DEBUG_TENSOR_DUMP_INPUT_FILE] + [--enable-memory-saver] [--enable-weights-cpu-backup] + [--enable-draft-weights-cpu-backup] + [--enable-custom-logit-processor] + [--enable-return-hidden-states] + [--return-hidden-states-mode {last,full}] + [--enable-return-routed-experts] + [--enable-return-indexer-topk] + [--sampling-mask-max-tokens SAMPLING_MASK_MAX_TOKENS] + [--disable-outlines-disk-cache] [--enable-mis] + [--weight-cache-mode {off,daemon,client}] + [--weight-cache-socket WEIGHT_CACHE_SOCKET] + [--weight-cache-timeout WEIGHT_CACHE_TIMEOUT] + [--forward-hooks FORWARD_HOOKS] + [--msprobe-dump-config MSPROBE_DUMP_CONFIG] + [--ple-offload-embedding | --no-ple-offload-embedding] + [--ple-offload-backend {pinned,file}] + [--ple-offload-dir PLE_OFFLOAD_DIR] + [--disaggregation-enable-kv-checksum] + [--enable-pd-role-switch] + [--speculative-domino-candidate-pool-size SPECULATIVE_DOMINO_CANDIDATE_POOL_SIZE] + [--flashinfer-a2a-dispatch-type {auto,bf16,nvfp4,mxfp8}] + [--cuda-graph-max-seq-len-prefill CUDA_GRAPH_MAX_SEQ_LEN_PREFILL] + [--prefill-kv-cache-dequant-dtype {auto,nvfp4,fp8_e4m3}] + [--dsv4-attn-backend {auto,flashmla,trtllm}] + [--otlp-service-name OTLP_SERVICE_NAME] + [--hicache-host-memory-fraction HICACHE_HOST_MEMORY_FRACTION] + [--enable-linker-mla-dedup] + [--swa-prefix-tails SWA_PREFIX_TAILS] + [--enable-encoder-swa-bounded-replay] + [--enable-decoder-swa-bounded-replay] + [--enable-response-store] + [--disaggregation-decode-host-receive-threshold DISAGGREGATION_DECODE_HOST_RECEIVE_THRESHOLD] + [--qsa-indexer-dtype {auto,bfloat16,fp8_e4m3}] + [--disaggregation-decode-allocation-policy {early,prefill_complete}] + [--speculative-use-block-verification] + [--speculative-draft-sink-size SPECULATIVE_DRAFT_SINK_SIZE] + [--disable-aiter-allreduce-fusion-in-prefill] + [--disable-aiter-allreduce-fusion-in-decode] + [--dsa-triton-union {0,2,4}] + [--enable-dsa-fused-indexer | --no-enable-dsa-fused-indexer] + [--enable-watermark] [--watermark-config WATERMARK_CONFIG] + [--grpc-response-timeout-secs GRPC_RESPONSE_TIMEOUT_SECS] + [--sampling-backend {pytorch,flashinfer,ascend,intel_xpu}] + [--reasoning-parser {auto,apertus2509,deepseek-r1,deepseek-v3,deepseek-v4,deepseek-v41,dots,glm45,ling3,hunyuan,gpt-oss,k2_horizon,kimi,kimi_k2,kimi_k3,mimo,muse,poolside_v1,qwen3,qwen3-thinking,minimax,minimax-append-think,minimax-m3,nanbeige,step3,step3p5,mistral,nemotron_3,granite_thinking_parser,interns1,gemma4,inkling,iquest_q1,cohere_command4,gigachat35}] + [--tool-call-parser {auto,apertus2509,cohere_command4,deepseekv3,deepseekv31,deepseekv32,deepseekv4,deepseekv41,dots,glm,glm45,glm47,gpt-oss,k2_horizon,kimi_k2,kimi_k3,lfm2,ling3,llama3,mimo,minicpm5,mistral,muse,poolside_v1,pythonic,qwen,qwen25,qwen3_coder,spark25,step3,step3p5,minimax-m2,minimax-m3,nanbeige,trinity,interns1,hermes,hunyuan,gigachat3,gigachat35,gemma4,inkling,iquest_q1}] + [--kv-canary-real-data {none,partial,all}] + [--config CONFIG] [--disable-cuda-graph] + [--enable-dp-attention] + [--enable-flashinfer-allreduce-fusion] + [--servicer-impl {python,rust}] + +options: + -h, --help show this help message and exit + --model-path MODEL_PATH, --model MODEL_PATH + The path of the model weights. This can be a local + folder or a Hugging Face repo ID. + --tokenizer-path TOKENIZER_PATH + The path of the tokenizer. + --tokenizer-mode {auto,slow} + Tokenizer mode. 'auto' will use the fast tokenizer if + available, and 'slow' will always use the slow + tokenizer. + --tokenizer-backend {huggingface,fastokens} + Tokenizer backend. 'huggingface' uses the default + HuggingFace tokenizers library, and 'fastokens' uses + the fastokens library for faster tokenization. + Requires the fastokens package to be installed. + --tokenizer-worker-num TOKENIZER_WORKER_NUM + The worker num of the tokenizer manager. + --detokenizer-worker-num DETOKENIZER_WORKER_NUM + The worker num of the detokenizer manager. + --skip-tokenizer-init + If set, skip init tokenizer and pass input_ids in + generate request. + --load-format {auto,pt,safetensors,instanttensor,npcache,dummy,sharded_state,presharded,gguf,expert_pack,bitsandbytes,mistral,layered,flash_rl,remote,remote_instance,fastsafetensors,private,runai_streamer} + The format of the model weights to load. "auto" will + try to load the weights in the safetensors format and + fall back to the pytorch bin format if safetensors + format is not available. "pt" will load the weights in + the pytorch bin format. "safetensors" will load the + weights in the safetensors format. "instanttensor" + will load Safetensors weights using InstantTensor's + high-performance distributed loader. "npcache" will + load the weights in pytorch format and store a numpy + cache to speed up the loading. "dummy" will initialize + the weights with random values, which is mainly for + profiling."gguf" will load the weights in the gguf + format. "expert_pack" is experimental and loads only + the validated DeepSeek-V4-Flash-0731 MXFP4 or text- + only Kimi-K3 Q2_K GGUF model with routed experts + stored in an SSD expert pack. "bitsandbytes" will load + the weights using bitsandbytes quantization."layered" + loads weights layer by layer so that one can quantize + a layer before loading another to make the peak memory + envelope smaller."presharded" performs a normal first- + time load (with quantization), then dumps a per- + rank/per-tensor sharded checkpoint with content + deduplication into + <model_path>/presharded/<parallelism+quant + subfolder>/. Subsequent runs with the same + parallelism+quantization config load directly from + this presharded checkpoint and skip re-quantization. + The dump directory must be on a shared filesystem + across all ranks/nodes. Optional + model_loader_extra_config roots: presharded_path + (target) and draft_presharded_path (speculative + draft); each replaces <model_path>/presharded and + still gets a config subfolder appended. Use a writable + path when model_path is read-only (e.g. HF cache + mounts). + --model-loader-extra-config MODEL_LOADER_EXTRA_CONFIG + Extra config for model loader. This will be passed to + the model loader corresponding to the chosen + load_format. For load_format=presharded, JSON may + include presharded_path (target cache root), + draft_presharded_path (draft cache root), + max_file_bytes, hash_num_threads, and verify_on_load. + --trust-remote-code Whether or not to allow for custom models defined on + the Hub in their own modeling files. + --context-length CONTEXT_LENGTH + The model's maximum context length. Defaults to None + (will use the value from the model's config.json + instead). Supports standard SI suffixes (k, M, G, T) + and IEC suffixes (Ki, Mi, Gi, Ti). Suffixes are case- + sensitive. Decimals are allowed for SI suffixes only. + Examples: '1k' -> 1000 '1M' -> 1000000 '25.6k' -> + 25600 '1Ki' -> 1024 '1Mi' -> 1048576 + --is-embedding Whether to use a CausalLM as an embedding model. + --enable-multimodal Enable the multimodal functionality for the served + model. If the model being served is not multimodal, + nothing will happen + --revision REVISION The specific model version to use. It can be a branch + name, a tag name, or a commit id. If unspecified, will + use the default version. + --model-impl MODEL_IMPL + Which implementation of the model to use. * "auto" + will try to use the SGLang implementation if it exists + and fall back to the Transformers implementation if no + SGLang implementation is available. * "sglang" will + use the SGLang model implementation. * "transformers" + will use the Transformers model * "mindspore" will use + the MindSpore model implementation. + --model-config-parser MODEL_CONFIG_PARSER + Which model-config parser to use. "auto" picks + "mistral" via the is_mistral_model name heuristic, + else "hf" (AutoConfig over config.json). Plugins can + register additional parsers via + @register_model_config_parser. + --json-model-override-args JSON_MODEL_OVERRIDE_ARGS + A dictionary in JSON string format used to override + default model configurations. + --dtype {auto,half,float16,bfloat16,float,float32} + Data type for model weights and activations. * "auto" + will use FP16 precision for FP32 and FP16 models, and + BF16 precision for BF16 models. * "half" for FP16. + Recommended for AWQ quantization. * "float16" is the + same as "half". * "bfloat16" for a balance between + precision and range. * "float" is shorthand for FP32 + precision. * "float32" for FP32 precision. + --quantization {awq,fp8,mxfp8,gptq,gptq_marlin,awq_marlin,bitsandbytes,gguf,modelopt,modelopt_fp8,modelopt_fp4,nvfp4_online,modelopt_mixed,petit_nvfp4,w8a8_int8,w8a8_fp8,moe_wna16,w4afp8,mxfp4,auto-round,auto-round-int8,compressed-tensors,modelslim,mxfp_w4a8,quark,quark_int4fp8_moe,quark_mxfp4,mlx_q4,mlx_q8,unquant,humming} + The quantization method. + --quantization-param-path QUANTIZATION_PARAM_PATH + Path to the JSON file containing the KV cache scaling + factors. This should generally be supplied, when KV + cache dtype is FP8. Otherwise, KV cache scaling + factors default to 1.0, which may cause accuracy + issues. + --kv-cache-dtype {auto,fp8_e5m2,fp8_e4m3,mxfp8,bf16,bfloat16,nvfp4,fp4_mx_block16} + Data type for kv cache storage. "auto" will use model + data type. "bf16" or "bfloat16" for BF16 KV cache. + "fp8_e5m2" and "fp8_e4m3" are supported for CUDA + 11.8+. "mxfp8" is supported by the FA4 backend. + "nvfp4" selects the NVFP4 FP4 E2M1 KV cache recipe; + "fp4_mx_block16" selects the MX-style block-size-16 + FP4 E2M1 KV cache recipe. Both require CUDA 12.8+ and + PyTorch 2.8.0+ + --enable-fp32-lm-head + If set, the LM head outputs (logits) are in FP32. + --modelopt-quant MODELOPT_QUANT + The ModelOpt quantization configuration. Supported + values: 'fp8', 'int4_awq', 'w4a8_awq', 'nvfp4', + 'nvfp4_awq'. This requires the NVIDIA Model Optimizer + library to be installed: pip install nvidia-modelopt + --modelopt-checkpoint-restore-path MODELOPT_CHECKPOINT_RESTORE_PATH + Path to restore a previously saved ModelOpt quantized + checkpoint. If provided, the quantization process will + be skipped and the model will be loaded from this + checkpoint. + --modelopt-checkpoint-save-path MODELOPT_CHECKPOINT_SAVE_PATH + Path to save the ModelOpt quantized checkpoint after + quantization. This allows reusing the quantized model + in future runs. + --modelopt-export-path MODELOPT_EXPORT_PATH + Path to export the quantized model in HuggingFace + format after ModelOpt quantization. The exported model + can then be used directly with SGLang for inference. + If not provided, the model will not be exported. + --quantize-and-serve Quantize the model with ModelOpt and immediately serve + it without exporting. This is useful for development + and prototyping. For production, it's recommended to + use separate quantization and deployment steps. + --rl-quant-profile RL_QUANT_PROFILE + Path to the FlashRL quantization profile. Required + when using --load-format flash_rl. + --enable-tf32-matmul Enable float32 matmuls to use TensorFloat32 precision + for better performance (via + torch.set_float32_matmul_precision). CUDA only. + --mem-fraction-static MEM_FRACTION_STATIC + The fraction of the memory used for static allocation + (model weights and KV cache memory pool). Use a + smaller value if you see out-of-memory errors. + --max-running-requests MAX_RUNNING_REQUESTS + The maximum number of running requests. + --max-queued-requests MAX_QUEUED_REQUESTS + The maximum number of queued requests. This option is + ignored when using disaggregation-mode. + --max-total-tokens MAX_TOTAL_TOKENS + The maximum number of tokens in the memory pool. If + not specified, it will be automatically calculated + based on the memory usage fraction. This option is + typically used for development and debugging purposes. + Supports standard SI suffixes (k, M, G, T) and IEC + suffixes (Ki, Mi, Gi, Ti). Suffixes are case- + sensitive. Decimals are allowed for SI suffixes only. + Examples: '1k' -> 1000 '1M' -> 1000000 '25.6k' -> + 25600 '1Ki' -> 1024 '1Mi' -> 1048576 + --chunked-prefill-size CHUNKED_PREFILL_SIZE + The maximum number of tokens in a chunk for the + chunked prefill. Setting this to -1 means disabling + chunked prefill. + --prefill-decode-interval PREFILL_DECODE_INTERVAL + The number of decode rounds to run after a prefill + batch before scheduling the next prefill. By default, + this is disabled except for profiled Qwen3-VL serving + configurations on Hopper. In data-parallel attention + mode, the interval is synchronized across all DP + ranks. Set to 0 to disable. + --enable-dynamic-chunking + Enable dynamic chunk size adjustment for pipeline + parallelism. When enabled, chunk sizes are dynamically + calculated based on fitted function to maintain + consistent execution time across chunks. + --max-prefill-tokens MAX_PREFILL_TOKENS + The maximum number of tokens in a prefill batch. The + real bound will be the maximum of this value and the + model's maximum context length. Supports standard SI + suffixes (k, M, G, T) and IEC suffixes (Ki, Mi, Gi, + Ti). Suffixes are case-sensitive. Decimals are allowed + for SI suffixes only. Examples: '1k' -> 1000 '1M' -> + 1000000 '25.6k' -> 25600 '1Ki' -> 1024 '1Mi' -> + 1048576 + --prefill-max-requests PREFILL_MAX_REQUESTS + The maximum number of requests in a prefill batch. If + not specified, there is no limit. + --schedule-policy {lpm,random,fcfs,dfs-weight,lof,priority,routing-key,hrrn,shortest-prefill-first} + The scheduling policy of the requests. + --enable-priority-scheduling + Enable priority scheduling. Requests with higher + priority integer values will be scheduled first by + default. + --disable-priority-preemption + Disable priority scheduling preemption. + --default-priority-value DEFAULT_PRIORITY_VALUE + Default priority for requests without explicit + priority. + --abort-on-priority-when-disabled + If set, abort requests that specify a priority when + priority scheduling is disabled. + --schedule-low-priority-values-first + If specified with --enable-priority-scheduling, the + scheduler will schedule requests with lower priority + integer values first. + --priority-scheduling-preemption-threshold PRIORITY_SCHEDULING_PREEMPTION_THRESHOLD + Minimum difference in priorities for an incoming + request to have to preempt running request(s). + --retraction-policy {length,priority} + The decode retraction policy to use when the KV cache + is full. 'length' preserves the existing behavior and + retracts short-output, long-input requests first. + 'priority' retracts lower-priority requests first, + using the same priority direction as priority + scheduling. + --schedule-conservativeness SCHEDULE_CONSERVATIVENESS + How conservative the schedule policy is. A larger + value means more conservative scheduling. Use a larger + value if you see requests being retracted frequently. + --page-size PAGE_SIZE + The number of tokens in a page. + --c128-page-size C128_PAGE_SIZE + The physical page size of the NPU DSV4 C128 KV cache. + Must be a positive multiple of 16. + --swa-full-tokens-ratio SWA_FULL_TOKENS_RATIO + The ratio of SWA layer KV tokens / full layer KV + tokens, regardless of the number of swa:full layers. + It should be between 0 and 1. E.g. 0.5 means if each + swa layer has 50 tokens, then each full layer has 100 + tokens. + --disable-hybrid-swa-memory + Disable the hybrid SWA memory pool. + --radix-eviction-policy {lru,lfu,slru,priority,tlru} + The eviction policy of radix trees. 'lru' stands for + Least Recently Used, 'lfu' stands for Least Frequently + Used, 'slru' stands for Segmented Least Recently Used, + 'priority' evicts lower-priority requests first, and + 'tlru' stands for Tail-Optimized LRU + (arXiv:2510.15152), which evicts the part of a + conversation that cannot affect tail TTFT before + falling back to LRU. See https://docs.sglang.io/docs/a + dvanced_features/radix_eviction_policy for what each + policy optimizes for. + --radix-eviction-policy-config RADIX_EVICTION_POLICY_CONFIG + Tuning parameters for --radix-eviction-policy, as a + json object passed to the policy as keyword arguments. + 'slru' takes protected_threshold (int, default 2), + e.g. '{"protected_threshold": 4}'; 'tlru' takes + threshold and next_prompt_estimate (ints, tokens), + e.g. '{"threshold": 4096, "next_prompt_estimate": + 512}'. An unrecognized key fails at startup, naming + the key and the policy. See https://docs.sglang.io/doc + s/advanced_features/radix_eviction_policy#policy- + parameters for the full parameter list. + --prefill-only-disable-kv-cache + Skip the physical KV cache allocation for embedding- + mode prefill-only workloads. Currently only valid with + --is-embedding, --chunked-prefill-size=-1, --disable- + radix-cache, an FA prefill backend, and non-FP4 KV + cache so the fa_skip_kv_cache path is active (no layer + reads or writes the cache). Other prefill-only + workloads such as scoring/MIS may benefit from this + later once their attention paths stop using paged KV. + Scheduler admission accounting is unchanged; per-layer + K/V tensors are sized to (page_size, head_num, + head_dim) placeholders so GPU memory is not wasted. + --disable-radix-cache + Disable RadixAttention for prefix caching. + --enable-page-major-kv-layout + Store the Mamba state and full/SWA KV caches as one + entry per token slot, holding every layer's state, in + a single page-granular buffer instead of one buffer + per layer. Implied by --enable-unified-memory, and + currently supported only with it. + --enable-unified-memory + Replace the statically-partitioned hybrid-model pools + (full-attn KV + SWA/Mamba state) with one byte buffer + split dynamically between sub-pools. Requires stride- + aware attention, linear-attention and Mamba backends; + the allowed backends and the supported PD- + disaggregation and speculative-decoding configurations + are validated at startup. + --disable-chunked-prefix-cache + Disable chunked prefix cache feature for deepseek, + which should save overhead for short sequences. + --disable-overlap-schedule + Disable the overlap scheduler, which overlaps the CPU + scheduler with GPU model worker. + --num-continuous-decode-steps NUM_CONTINUOUS_DECODE_STEPS + Run multiple continuous decoding steps to reduce + scheduling overhead. This can potentially increase + throughput but may also increase time-to-first-token + latency. The default value is 1, meaning only run one + decoding step at a time. + --scheduler-recv-interval SCHEDULER_RECV_INTERVAL + The interval to poll requests in scheduler. Can be set + to >1 to reduce the overhead of this. + --enable-mixed-chunk Enabling mixing prefill and decode in a batch when + using chunked prefill. + --nccl-port NCCL_PORT + The port for NCCL distributed environment setup. + Defaults to a random port. + --dist-timeout DIST_TIMEOUT + Set timeout for torch.distributed initialization. + --dist-init-addr DIST_INIT_ADDR, --nccl-init-addr DIST_INIT_ADDR + The host address for initializing distributed backend + (e.g., `192.168.0.2:25000`). + --gated-launch-port GATED_LAUNCH_PORT + The port of the gated launch control server. When set, + every rank blocks right after the distributed + environment is initialized, before any sizable GPU + allocation, until `POST /gate/activate` is sent to + this port on the host of the first rank. This lets an + external orchestrator defer the memory hungry part of + startup to a safe window. Defaults to None, which + disables the gate. + --nnodes NNODES The number of nodes. + --node-rank NODE_RANK + The node rank. + --tp-size TP_SIZE, --tensor-parallel-size TP_SIZE + The tensor parallelism size. + --dcp-size DCP_SIZE, --decode-context-parallel-size DCP_SIZE + The decode context parallelism size. + --pp-size PP_SIZE, --pipeline-parallel-size PP_SIZE + The pipeline parallelism size. + --pp-max-micro-batch-size PP_MAX_MICRO_BATCH_SIZE + The maximum micro batch size in pipeline parallelism. + --pp-async-batch-depth PP_ASYNC_BATCH_DEPTH + The async batch depth of pipeline parallelism. + --dp-size DP_SIZE, --data-parallel-size DP_SIZE + The number of data-parallel replicas of the model. + --attn-dp-size ATTN_DP_SIZE, --attention-data-parallel-size ATTN_DP_SIZE + The attention data parallelism size: the number of + data-parallel attention groups inside the TP group, + one scheduler each, while the FFN stays tensor + parallel. + --load-balance-method {auto,round_robin,follow_bootstrap_room,total_requests,total_tokens} + The load balancing strategy for data parallelism. + --attn-cp-size ATTN_CP_SIZE, --attention-context-parallel-size ATTN_CP_SIZE + The attention context parallelism size. + --moe-dp-size MOE_DP_SIZE, --moe-data-parallel-size MOE_DP_SIZE + The moe data parallelism size. + --dwdp-size DWDP_SIZE + DWDP (Distributed Weight Data Parallelism) group size. + When > 1, MoE prefill uses weight prefetch instead of + token all-to-all. Must equal tp_size. Only supported + with --disaggregation-mode null or prefill. + --dcp-comm-backend {ag_rs,a2a,fi_a2a} + Communication backend for the decode context-parallel + (DCP) attention reduction: 'ag_rs' (AllGather + + ReduceScatter), 'a2a' (one All-to-All exchange of + output+LSE, then a local LSE combine), or 'fi_a2a' + (FlashInfer MNNVL All-to-All kernel; requires + Blackwell and a DCP group within one MNNVL domain). + Unset resolves to 'fi_a2a' where supported, else 'a2a' + on CUDA/ROCm/XPU, else 'ag_rs'. + --dcp-replicate-q-proj, --no-dcp-replicate-q-proj + For MLA decode context parallelism with the a2a/fi_a2a + backend: replicate the Q projection so each DCP rank + computes the full-head query locally (redundant + projection compute), eliminating the per-layer head- + dim all-gather of Q. Trades a small amount of extra + GEMM for one fewer collective per layer. Use --no-dcp- + replicate-q-proj to disable the model-specific + default. + --enable-prefill-cp Enable context parallelism for the prefill phase. + Select the layout with --cp-strategy. + --cp-strategy {zigzag,interleave} + Sharding strategy for prefill CP. 'zigzag' assigns + each rank one early and one late sequence block; + 'interleave' assigns token indices modulo the CP size. + --enable-dsa-cache-layer-split + Split DSA (DeepSeek Sparse Attention) GPU KV/indexer + cache layers across context-parallel ranks to reduce + per-rank KV memory. Currently only supported with the + mooncake transfer backend (mooncake / mooncake_tcp); + mori/nixl support will be added later by the + community. + --enable-cp-decode-attn-tp + Enable attention tensor-parallel weight slicing during + decode under context parallel (cp_size>1). Slices the + replicated attention linears to the local CP + partition, eliminating redundant decode GEMMs. + --enable-kv-cache-sharding + Shard at-rest KV cache storage at page granularity + across the group that currently replicates it + (attention-CP ranks for GQA models under prefill CP; + attention-TP ranks for MLA models), multiplying the + group's unique KV capacity. Prefill workers of PD + disaggregation only. + --enable-dp-attention-local-control-broadcast + With DP-attention, send control messages to every DP + group leader and broadcast within attn_tp_group + instead of the full tp_group. Eliminates a costly all- + ranks gloo sync on every scheduler iteration. + --enable-dp-lm-head Enable vocabulary parallel across the attention TP + group to avoid all-gather across DP groups, optimizing + performance under DP attention. + --enable-tp-lm-head-all-to-all, --no-enable-tp-lm-head-all-to-all + Use all-to-all instead of TP all-gather followed by DP + scatter for the TP-sharded LM head under DP attention. + By default this is enabled only on decode-only PD + nodes with pure DP attention (tp_size == attn_dp_size + > 1 and attn_cp_size == 1), and disabled on prefill- + only and colocated nodes. Pass --no-enable-tp-lm-head- + all-to-all to opt out. The path is incompatible with + --enable-dp-lm-head; batches without an equal padded + row count fall back to the existing all-gather path. + --enable-attn-tp-input-scattered + Allow input of attention to be scattered when only + using tensor parallelism, to reduce the computational + load of operations such as qkv latent. + --enable-shared-experts-attn-tp + Shard shared expert weights across the attention TP + group when using an expert-parallel all-to-all + backend. + --shared-experts-tp-size SHARED_EXPERTS_TP_SIZE + Shared-expert TP size for Kimi-K3 with an expert- + parallel all-to-all backend. Must divide attention TP + size. Overrides --enable-shared-experts-attn-tp when + set; 1 replicates the weights. + --enable-dense-mlp-attn-tp + Shard dense MLP weights across the attention TP group + under DP attention. + --enable-layernorm-sp + Enable Megatron-style sequence parallelism + (arXiv:2205.05198) for the LayerNorm/residual regions + under pure tensor parallelism: the row-parallel all- + reduce becomes reduce-scatter + all-gather, so + LayerNorm runs on sequence-sharded activations with no + extra communication volume. Prefill only; Qwen3 dense; + requires tp_size > 1 and NVLink/NVSwitch. + --disable-attn-tp-gather + Disable scheduler-side attn_tp_gather (the upstream SP + path that pads num_tokens to attn_tp_size and pre- + allocates a gathered buffer). Use for models that + manage SP scatter/gather at the model level (e.g., + perform their own all_gather/reduce_scatter inside + attention) and do not consume the upstream + gathered_buffer. Without this, the cuda graph runner + pads num_tokens to attn_tp_size, which can cause + kernel autotuners to select wrong-sized variants at + small batches. + --enable-p2p-check Enable P2P check for GPU access, otherwise the p2p + access is allowed by default. + --device DEVICE The device to use ('cuda', 'xpu', 'hpu', 'npu', 'cpu', + 'musa'). Defaults to auto-detection if not specified. + --base-gpu-id BASE_GPU_ID + The base GPU ID to start allocating GPUs from. Useful + when running multiple instances on the same machine. + --gpu-id-step GPU_ID_STEP + The delta between consecutive GPU IDs that are used. + For example, setting it to 2 will use GPU 0,2,4,... + --random-seed RANDOM_SEED + The random seed. + --mlx-enable-sampling + MLX backend only: sample decode tokens (temperature / + top-k / top-p / min-p) instead of greedy argmax. + Sampling runs inside the lazy MLX graph, so it works + with the overlap scheduler; first tokens from + prefill/extend are sampled too. Greedy requests keep + exact argmax behavior. Also enables on the MLX path: + grammar vocab masks and custom logit processors (these + break decode chaining per step; custom processors run + on pure-decode steps only), logit_bias, output + logprobs (sampled token / top-k / token_ids; prompt + input logprobs are not computed), NaN sanitization + (SGLANG_SANITIZE_NAN_LOGITS), and per-request + sampling_seed under --enable-deterministic-inference + (deterministic within MLX only). Penalties are not + applied. + --watchdog-timeout WATCHDOG_TIMEOUT + Set watchdog timeout in seconds. If a forward batch + takes longer than this, the server will crash to + prevent hanging. + --soft-watchdog-timeout SOFT_WATCHDOG_TIMEOUT + Set soft watchdog timeout in seconds. If a forward + batch takes longer than this, the server will dump + information for debugging. + --sleep-on-idle Reduce CPU usage when sglang is idle. + --use-ray Use Ray actors for scheduler process management. + --numa-node NUMA_NODE [NUMA_NODE ...] + Sets the numa node for the subprocesses. i-th element + corresponds to i-th subprocess. If unset, will be + automatically detected on NUMA systems. + --gc-threshold GC_THRESHOLD [GC_THRESHOLD ...] + Set the garbage collection thresholds (the collection + frequency). Accepts 1 to 3 integers. + --host HOST The host of the HTTP server. + --port PORT The port of the HTTP server. + --fastapi-root-path FASTAPI_ROOT_PATH + App is behind a path based routing proxy. + --smg-grpc-mode Use the legacy SMG gRPC server (smg-grpc-servicer) + instead of the HTTP server. Replaces the deprecated + --grpc-mode. + --grpc-mode (Deprecated, use --smg-grpc-mode) Legacy SMG gRPC + server selector. + --grpc-port GRPC_PORT + Port for the native gRPC server, started alongside + HTTP. Setting this (or SGLANG_GRPC_PORT) enables the + native gRPC server; it is off by default. Follower + nodes expose GetServerInfo only. In legacy --smg-grpc- + mode this is the SMG server port and defaults to + --port + 10000. + --sidecar SIDECAR Start a locally managed sidecar against the native + gRPC server. The selected module must expose + main(argv) and read the resolved native gRPC endpoint + from SGLANG_GRPC_ENDPOINT. Requires --grpc-port or + SGLANG_GRPC_PORT. + --sidecar-args SIDECAR_ARGS + JSON array passed to the selected sidecar module's + main(argv) function. --sidecar-shutdown-timeout + SECONDS is consumed by SGLang. + --skip-server-warmup If set, skip warmup. + --warmups WARMUPS Specify custom warmup functions (csv) to run before + server starts eg. --warmups=warmup_name1,warmup_name2 + will run the functions `warmup_name1` and + `warmup_name2` specified in warmup.py before the + server starts listening for requests + --enable-http2 Use Granian instead of Uvicorn as the ASGI server, + enabling HTTP/1.1 and HTTP/2 auto-negotiation. Clients + may use h2c (cleartext HTTP/2) or plain HTTP/1.1. + Requires 'pip install sglang[http2]'. + --http2-max-concurrent-streams HTTP2_MAX_CONCURRENT_STREAMS + Maximum number of concurrent streams advertised on + each HTTP/2 connection (1 to 2^32 - 1). Only applies + with --enable-http2. + --http2-initial-connection-window-size HTTP2_INITIAL_CONNECTION_WINDOW_SIZE + Initial connection-level HTTP/2 receive window in + bytes (1024 to 2^31 - 1). Only applies with --enable- + http2. + --ssl-keyfile SSL_KEYFILE + The file path to the SSL key file. + --ssl-certfile SSL_CERTFILE + The file path to the SSL certificate file. + --ssl-ca-certs SSL_CA_CERTS + The CA certificates file. + --ssl-keyfile-password SSL_KEYFILE_PASSWORD + The password to decrypt the SSL keyfile. + --enable-ssl-refresh Enable automatic SSL certificate hot-reloading when + cert/key files change on disk. Requires --ssl-certfile + and --ssl-keyfile. + --api-key API_KEY Set API key of the server. It is also used in the + OpenAI API compatible server. + --admin-api-key ADMIN_API_KEY + Set admin API key for sensitive management endpoints + (e.g. /hicache/storage-backend/clear). When set, admin + endpoints require this key and do NOT accept --api- + key. + --served-model-name SERVED_MODEL_NAME + Override the model name returned by the v1/models + endpoint in OpenAI API server. + --weight-version WEIGHT_VERSION + Version identifier for the model weights. Defaults to + 'default' if not specified. + --chat-template CHAT_TEMPLATE + The buliltin chat template name or the path of the + chat template file. This is only used for OpenAI- + compatible API server. + --trust-request-chat-template + Allow a request to override the server chat template + via its chat_template_kwargs. Off by default for + safety. + --hf-chat-template-name HF_CHAT_TEMPLATE_NAME + When the HuggingFace tokenizer has multiple chat + templates (e.g., 'default', 'tool_use', 'rag'), + specify which named template to use. If not set, the + first available template is used. + --completion-template COMPLETION_TEMPLATE + The buliltin completion template name or the path of + the completion template file. This is only used for + OpenAI-compatible API server. only for code completion + currently. + --file-storage-path FILE_STORAGE_PATH + The path of the file storage in backend. + --enable-cache-report + Return number of cached tokens in + usage.prompt_tokens_details for each openai request. + --return-input-ids Return prompt (input) token ids on the response-level + sglext extension for every chat completion request, as + if return_input_ids_in_sglext were set on the request. + --return-output-ids Return sampled output token ids on the response-level + sglext extension for every chat completion request, as + if return_output_ids_in_sglext were set on the + request. + --default-chat-template-kwargs DEFAULT_CHAT_TEMPLATE_KWARGS + Default chat template kwargs applied to every request + when not overridden per-request. Keys must match what + the model's chat template expects (e.g. + enable_thinking, thinking, reasoning_effort). Per- + request chat_template_kwargs takes precedence. + --strip-thinking-cache + Skip caching reasoning-model output (thinking + + answer) in the radix tree on finish; keep only the + prompt prefix. Opt-in: changes cache contents. + --enable-strict-thinking + Enable strict token filtering during the thinking + phase. Blocks model-specific excluded tokens (e.g., + tool call markers) during reasoning. Requires a + grammar backend that supports token filtering. + --tool-server TOOL_SERVER + Either 'demo' or a comma-separated list of tool server + urls to use for the model. If not specified, no tool + server will be used. + --sampling-defaults {openai,model} + Where to get default sampling parameters. 'openai' + uses SGLang/OpenAI defaults (temperature=1.0, + top_p=1.0, etc.). 'model' uses the model's + generation_config.json to get the recommended sampling + parameters if available. Default is 'model'. + --asr-max-buffer-seconds ASR_MAX_BUFFER_SECONDS + Maximum seconds of PCM audio the streaming ASR + WebSocket handler will accumulate before closing the + session with a buffer_overflow error. Guards against + OOM when a client streams audio faster than inference + can consume it. Default 60s. + --asr-max-concurrent-sessions ASR_MAX_CONCURRENT_SESSIONS + Maximum number of concurrent realtime ASR WebSocket + sessions served by /v1/realtime. New connections + beyond this cap are accepted, sent an + error{code:too_many_sessions} frame, and closed. + Default 32. + --preferred-sampling-params PREFERRED_SAMPLING_PARAMS + json-formatted sampling settings that will be returned + in /get_model_info + --allow-auto-truncate + Allow automatically truncating requests that exceed + the maximum input length instead of returning an + error. + --stream-interval STREAM_INTERVAL + The interval (or buffer size) for streaming in terms + of the token length. A smaller value makes streaming + smoother, while a larger value makes the throughput + higher + --batch-notify-size BATCH_NOTIFY_SIZE + Number of streaming notifications to batch before + yielding to the event loop. Reduces asyncio wakeup + overhead under high concurrency. + --stream-response-default-include-usage + Include usage in every streaming response (even when + stream_options is not specified). + --incremental-streaming-output + Whether to output as a sequence of disjoint segments. + --enable-streaming-session + Enable streaming session mode and StreamingSession + wrapper. + --enable-session-radix-cache + Track per-session references on UnifiedRadixCache KV: + eviction consumes unreferenced entries before + referenced ones, and closing a session only + dereferences its KV. + --log-level LOG_LEVEL + The logging level of all loggers. + --log-level-http LOG_LEVEL_HTTP + The logging level of HTTP server. If not set, reuse + --log-level by default. + --log-requests Log metadata, inputs, outputs of all requests. The + verbosity is decided by --log-requests-level + --log-requests-level {0,1,2,3} + 0: Log metadata (no sampling parameters). 1: Log + metadata and sampling parameters. 2: Log metadata, + sampling parameters and partial input/output. 3: Log + every input/output. + --log-requests-format {text,json} + Format for request logging: 'text' (human-readable) or + 'json' (structured) + --log-requests-target LOG_REQUESTS_TARGET [LOG_REQUESTS_TARGET ...] + Target(s) for request logging: 'stdout' and/or + directory path(s) for file output. Can specify + multiple targets, e.g., '--log-requests-target stdout + /my/path'. + --uvicorn-access-log-exclude-prefixes [UVICORN_ACCESS_LOG_EXCLUDE_PREFIXES ...] + Exclude uvicorn access logs whose request path starts + with any of these prefixes. Defaults to empty + (disabled). Example: --uvicorn-access-log-exclude- + prefixes /metrics /health + --crash-dump-folder CRASH_DUMP_FOLDER + Folder path to dump requests from the last 5 min + before a crash (if any). If not specified, crash + dumping is disabled. + --show-time-cost Show time cost of custom marks. + --enable-metrics Enable log prometheus metrics. + --smg-http-sidecar-port SMG_HTTP_SIDECAR_PORT, --grpc-http-sidecar-port SMG_HTTP_SIDECAR_PORT + Port for the HTTP sidecar server in legacy SMG gRPC + mode (--smg-grpc-mode). Serves Prometheus metrics and + profiling endpoints. Defaults to --port + 1. Not used + in HTTP mode. + --enable-mfu-metrics Enable estimated MFU-related prometheus metrics. + --enable-metrics-for-all-schedulers + Enable --enable-metrics-for-all-schedulers when you + want schedulers on all TP ranks (not just TP 0) to + record request metrics separately. This is especially + useful when dp_attention is enabled, as otherwise all + metrics appear to come from TP 0. + --load-snapshot-publish-interval LOAD_SNAPSHOT_PUBLISH_INTERVAL + Publish load snapshot to shared memory every N decode + iterations. Prefill and idle always publish + immediately. + --tokenizer-metrics-custom-labels-header TOKENIZER_METRICS_CUSTOM_LABELS_HEADER + Specify the HTTP header for passing custom labels for + tokenizer metrics. + --tokenizer-metrics-allowed-custom-labels TOKENIZER_METRICS_ALLOWED_CUSTOM_LABELS [TOKENIZER_METRICS_ALLOWED_CUSTOM_LABELS ...] + The custom labels allowed for tokenizer metrics. The + labels are specified via a dict in '--tokenizer- + metrics-custom-labels-header' field in HTTP requests, + e.g., {'label1': 'value1', 'label2': 'value2'} is + allowed if '--tokenizer-metrics-allowed-custom-labels + label1 label2' is set. + --extra-metric-labels EXTRA_METRIC_LABELS + The custom labels for metrics. e.g. '{"label1": + "value1", "label2": "value2"}' + --bucket-time-to-first-token BUCKET_TIME_TO_FIRST_TOKEN [BUCKET_TIME_TO_FIRST_TOKEN ...] + The buckets of time to first token, specified as a + list of floats. + --bucket-inter-token-latency BUCKET_INTER_TOKEN_LATENCY [BUCKET_INTER_TOKEN_LATENCY ...] + The buckets of inter-token latency, specified as a + list of floats. + --bucket-e2e-request-latency BUCKET_E2E_REQUEST_LATENCY [BUCKET_E2E_REQUEST_LATENCY ...] + The buckets of end-to-end request latency, specified + as a list of floats. + --prompt-tokens-buckets PROMPT_TOKENS_BUCKETS [PROMPT_TOKENS_BUCKETS ...] + The buckets rule of prompt tokens. Supports 3 rule + types: 'default' uses predefined buckets; 'tse + <middle> <base> <count>' generates two sides + exponential distributed buckets (e.g., 'tse 1000 2 8' + generates buckets [984.0, 992.0, 996.0, 998.0, 1000.0, + 1002.0, 1004.0, 1008.0, 1016.0]).); 'custom <value1> + <value2> ...' uses custom bucket values (e.g., 'custom + 10 50 100 500'). + --generation-tokens-buckets GENERATION_TOKENS_BUCKETS [GENERATION_TOKENS_BUCKETS ...] + The buckets rule for generation tokens histogram. + Supports 3 rule types: 'default' uses predefined + buckets; 'tse <middle> <base> <count>' generates two + sides exponential distributed buckets (e.g., 'tse 1000 + 2 8' generates buckets [984.0, 992.0, 996.0, 998.0, + 1000.0, 1002.0, 1004.0, 1008.0, 1016.0]).); 'custom + <value1> <value2> ...' uses custom bucket values + (e.g., 'custom 10 50 100 500'). + --gc-warning-threshold-secs GC_WARNING_THRESHOLD_SECS + The threshold for long GC warning. If a GC takes + longer than this, a warning will be logged. Set to 0 + to disable. + --decode-log-interval DECODE_LOG_INTERVAL + The log and metrics reporting interval (in decode + iterations) for decode batches. + --enable-request-time-stats-logging + Enable per request time stats logging + --kv-events-config KV_EVENTS_CONFIG + Config in json format for NVIDIA dynamo KV event + publishing. Publishing will be enabled if this flag is + used. Runtime-load publishing for load-aware routers + is a separate opt-in; see --load-publish-endpoint. Set + replay_endpoint (e.g. tcp://*:5558) to let routers re- + fetch dropped batches; /server_info advertises its + port. + --load-publish-endpoint LOAD_PUBLISH_ENDPOINT + Opt in to the runtime-load PUB socket that load-aware + routers subscribe to. Off by default (unset or 'off'). + Use 'auto' to reserve the dp_size ports packed after + the --kv-events-config range, or a wildcard-host TCP + address (e.g. tcp://*:6000) to place it explicitly; + rank r binds port+r and /server_info advertises the + base under the kv_events block. Requires --kv-events- + config to describe a publisher (routers discover the + base through /server_info); startup fails if this is + set without one, is not bindable, or overlaps the KV + range. Note: 'auto' reserves 2*dp_size ports from the + KV base — space co-hosted engines accordingly. The + router-facing update cadence follows --load-snapshot- + publish-interval (shared to avoid double-collecting + the snapshot), so a large value there also staleness- + caps this feed. + --enable-forward-pass-metrics + Enable per-iteration forward pass metrics via ZMQ IPC. + External consumers (e.g. Dynamo planner) subscribe to + the IPC endpoint exposed in + server_args.forward_pass_metrics_ipc_name. + --enable-trace Enable opentelemetry trace + --trace-modules TRACE_MODULES + Select the components to trace. Available options are + 'request' and 'mooncake'. Format: <module1 + name>,<module2 name>,... + --otlp-traces-endpoint OTLP_TRACES_ENDPOINT + Config opentelemetry collector endpoint if --enable- + trace is set. format: <ip>:<port> + --export-metrics-to-file + Export performance metrics for each request to local + file (e.g. for forwarding to external systems). + --export-metrics-to-file-dir EXPORT_METRICS_TO_FILE_DIR + Directory path for writing performance metrics files + (required when --export-metrics-to-file is enabled). + --constrained-json-whitespace-pattern CONSTRAINED_JSON_WHITESPACE_PATTERN + (outlines and llguidance backends only) Regex pattern + for syntactic whitespaces allowed in JSON constrained + output. For example, to allow the model generate + consecutive whitespaces, set the pattern to [ ]* + --constrained-json-disable-any-whitespace + (xgrammar and llguidance backends only) Enforce + compact representation in JSON constrained output. + --constrained-json-max-whitespace-cnt CONSTRAINED_JSON_MAX_WHITESPACE_CNT + (xgrammar backend only) Max consecutive whitespace + chars allowed in JSON constrained output. None means + unbounded. + --attention-backend {triton,torch_native,flex_attention,dsa,nsa,qsa,dsv4,compressed,fa3,fa4,flashinfer,llada2_cfg_flashinfer,flashmla,trtllm_mla,cutedsl_mla,tokenspeed_mla,trtllm_mha,hpc_ops,minicpm_flashattn,minicpm_flashinfer,aiter,wave,intel_amx,ascend,intel_xpu} + Choose the kernels for attention layers. + --decode-attention-backend {triton,torch_native,flex_attention,dsa,nsa,qsa,dsv4,compressed,fa3,fa4,flashinfer,llada2_cfg_flashinfer,flashmla,trtllm_mla,cutedsl_mla,tokenspeed_mla,trtllm_mha,hpc_ops,minicpm_flashattn,minicpm_flashinfer,aiter,wave,intel_amx,ascend,intel_xpu} + Choose the kernels for decode attention layers (have + priority over --attention-backend). + --enable-lean-attention + Enable Lean (Work-Centric) Attention decode kernel for + long-context serving. When None (default), uses auto- + gate that activates Lean for long contexts and falls + back to standard kernel for short contexts. Set to + True to force enable, False to force disable. + --prefill-attention-backend {triton,torch_native,flex_attention,dsa,nsa,qsa,dsv4,compressed,fa3,fa4,flashinfer,llada2_cfg_flashinfer,flashmla,trtllm_mla,cutedsl_mla,tokenspeed_mla,trtllm_mha,hpc_ops,minicpm_flashattn,minicpm_flashinfer,aiter,wave,intel_amx,ascend,intel_xpu} + Choose the kernels for prefill attention layers (have + priority over --attention-backend). + --grammar-backend {xgrammar,outlines,llguidance,none} + Choose the backend for grammar-guided decoding. + --radix-cache-backend RADIX_CACHE_BACKEND + Name of a radix-cache backend previously registered + via register_radix_cache_backend. Omit this flag to + use the built-in default cache selection chain. + --mm-attention-backend {sdpa,fa3,fa4,triton_attn,ascend_attn,aiter_attn,flashinfer_cudnn,amx_attn,xpu_attn} + Set multimodal attention backend. + --fp8-gemm-backend {auto,deep_gemm,flashinfer_trtllm,flashinfer_cutlass,flashinfer_deepgemm,flashinfer_cutedsl,cutlass,triton,aiter} + Choose the runner backend for Blockwise FP8 GEMM + operations. Options: 'auto' (default, auto-selects + based on hardware; MXFP8 dense picks + flashinfer_cutedsl on SM100/SM103 and FlashInfer + CUTLASS on other supported Blackwell GPUs), + 'deep_gemm' (JIT-compiled; enabled by default on + NVIDIA Hopper (SM90) and Blackwell (SM100) when + DeepGEMM is installed), 'flashinfer_trtllm' (optimal + for Blackwell and low-latency), 'flashinfer_cutlass' + (FlashInfer CUTLASS groupwise FP8 GEMM), + 'flashinfer_cutedsl' (FlashInfer CuTe DSL MXFP8 GEMM + on SM100/SM103), 'flashinfer_deepgemm' (Hopper SM90 + only; uses swapAB optimization for small M dimensions + in decoding), 'cutlass' (optimal for SM120 GPUs), + 'triton' (fallback, widely compatible), 'aiter' (ROCm + only). + --fp4-gemm-backend {auto,flashinfer_cudnn,flashinfer_cutedsl,flashinfer_cutlass,flashinfer_trtllm,marlin} + Choose the runner backend for NVFP4 GEMM operations. + Options: 'auto' (default; selects flashinfer_cutedsl + on SM100, marlin on SM80-SM90, flashinfer_cutlass + otherwise (including SM120)), 'flashinfer_cutlass' + (FlashInfer CUTLASS backend), 'flashinfer_cudnn' + (FlashInfer cuDNN backend, optimal on CUDA 13+ with + cuDNN 9.15+), 'flashinfer_cutedsl' (FlashInfer CuTe + DSL backend), 'flashinfer_trtllm' (FlashInfer + TensorRT-LLM backend, requires different weight + preparation with shuffling), 'marlin' (weight-only + W4A16 fallback for SM80+). + --bf16-gemm-backend {auto,cutedsl,gemv,torch} + Choose the backend for unquantized BF16 GEMM + operations. Options: 'auto' (default; selects + 'cutedsl' on SM10x GPUs, except deterministic + inference selects 'torch'; otherwise uses cuBLAS via + torch.nn.functional.linear), 'cutedsl' (SGLang JIT + CuTe DSL TGV BF16 GEMM on SM10x; dispatches between + the allowlisted low-M Split-K kernel, the CuTe DSL + kernel, and cuBLAS; set + SGLANG_ENABLE_BF16_SPLITK_GEMM=0 to disable Split-K), + 'gemv', 'torch' (always uses cuBLAS via + torch.nn.functional.linear). + --dsa-prefill-backend {flashmla_sparse,flashmla_sparse_q8,flashmla_kv,flashmla_auto,flashinfer_sparse_mla,triton_sparse_mla,fa3,tilelang,triton,aiter,trtllm,intel_xpu} + DSA (DeepSeek Sparse Attention) prefill backend. If + not specified, auto-detects based on hardware and + kv_cache_dtype. + --dsv4-prefill-backend {auto,flashmla_sparse,flashmla_sparse_q8} + DeepSeek-V4 sparse prefill backend. 'auto' and + 'flashmla_sparse' use the existing BF16 sparse prefill + path; 'flashmla_sparse_q8' enables the Q8KV8 sparse + prefill path. + --dsa-decode-backend {flashmla_sparse,flashmla_sparse_q8,flashmla_kv,flashmla_auto,flashinfer_sparse_mla,fa3,tilelang,triton,aiter,trtllm,intel_xpu} + DSA (DeepSeek Sparse Attention) decode backend. If not + specified, auto-detects based on hardware and + kv_cache_dtype. + --dsa-paged-mqa-logits-backend {auto,deepgemm,cutedsl,aiter} + DSA indexer paged MQA logits kernel backend. Options: + 'auto' (default; DeepGEMM on CUDA, aiter on ROCm), + 'deepgemm', 'cutedsl' (CuTe DSL kernel, SM 100 + (Blackwell) only; wins at low batch size and long + context), 'aiter' (ROCm only). + --dsa-topk-backend {sgl-kernel,torch,flashinfer} + DSA indexer top-k backend for the target model. + Options: 'sgl-kernel', 'torch', 'flashinfer'. The + 'torch' backend currently requires + SGLANG_DSA_FUSE_TOPK=false. + --disable-flashinfer-autotune + Disable FlashInfer autotuning. + --flashinfer-autotune-skip-ops FLASHINFER_AUTOTUNE_SKIP_OPS [FLASHINFER_AUTOTUNE_SKIP_OPS ...] + FlashInfer custom-op identifiers to skip during + autotuning. Skipped ops use FlashInfer's heuristic + fallback. SGLang temporarily skips mxfp8_gemm by + default due to an IMA. + --mamba-backend {triton,flashinfer} + Choose the kernel backend for Mamba SSM operations. + Default is 'triton'. Options: 'triton' (default), + 'flashinfer' (requires FlashInfer with Mamba support). + --cuda-graph-config CUDA_GRAPH_CONFIG + Per-phase CUDA graph settings as JSON, e.g. '{"decode" + :{"backend":"full","max_bs":256},"prefill":{"backend": + "breakable"}}'. Allowed backends per phase: full, + breakable, disabled. JSON wins over the per-phase + --cuda-graph-* convenience flags and over legacy + flags. + --cuda-graph-backend-decode {full,breakable,disabled} + Backend for the decode phase. Folds into + cuda_graph_config[decode].backend. + --cuda-graph-backend-prefill {full,breakable,disabled} + Backend for the prefill phase. Folds into + cuda_graph_config[prefill].backend. + --cuda-graph-max-bs-decode CUDA_GRAPH_MAX_BS_DECODE + Maximum batch size captured for the decode cuda graph. + --cuda-graph-max-bs-prefill CUDA_GRAPH_MAX_BS_PREFILL + Maximum batch size captured for the prefill cuda + graph. + --cuda-graph-bs-decode CUDA_GRAPH_BS_DECODE [CUDA_GRAPH_BS_DECODE ...] + Explicit list of batch sizes to capture for the decode + cuda graph. + --cuda-graph-bs-prefill CUDA_GRAPH_BS_PREFILL [CUDA_GRAPH_BS_PREFILL ...] + Explicit list of batch sizes to capture for the + prefill cuda graph. + --cuda-graph-prefill-max-context CUDA_GRAPH_PREFILL_MAX_CONTEXT, --context-bucket CUDA_GRAPH_PREFILL_MAX_CONTEXT + Maximum context length supported by DeepSeek-V4 + breakable/full prefill CUDA graphs. Context-shaped + attention metadata and indexer logits are allocated at + this fixed size instead of the model maximum. Larger + live contexts fall back to eager. Supports standard SI + suffixes (k, M, G, T) and IEC suffixes (Ki, Mi, Gi, + Ti). Suffixes are case-sensitive. Decimals are allowed + for SI suffixes only. Examples: '1k' -> 1000 '1M' -> + 1000000 '25.6k' -> 25600 '1Ki' -> 1024 '1Mi' -> + 1048576 + --disable-prefill-cuda-graph + Disable the prefill-phase CUDA graph. Convenience for + --cuda-graph-backend-prefill=disabled. + --disable-decode-cuda-graph + Disable the decode-phase CUDA graph. Convenience for + --cuda-graph-backend-decode=disabled. + --disable-cuda-graph-padding + Disable cuda graph when padding is needed. Still uses + cuda graph when padding is not needed. + --enable-profile-cuda-graph + Enable profiling of cuda graph capture. + --enable-cudagraph-gc + Enable garbage collection during CUDA graph capture. + If disabled (default), GC is frozen during capture to + speed up the process. + --debug-cuda-graph Enable debug/eager mode for CUDA graph using breakable + CUDA graph. When enabled, graph breaks are inserted so + every operation runs eagerly while still going through + the CUDA graph capture / replay path. Useful for + debugging CUDA graph capture / replay issues. + --enable-layerwise-nvtx-marker + Enable layerwise NVTX profiling annotations for the + model. + --enable-nccl-nvls Enable NCCL NVLS for prefill heavy requests when + available. + --enable-symm-mem Enable NCCL symmetric memory for fast collectives. + --triton-attention-reduce-in-fp32 + Cast the intermediate attention results to fp32 to + avoid possible crashes related to fp16.This only + affects Triton attention kernels. + --triton-attention-num-kv-splits TRITON_ATTENTION_NUM_KV_SPLITS + The number of KV splits in flash decoding Triton + kernel. Larger value is better in longer context + scenarios. The default value is 8. + --triton-attention-split-tile-size TRITON_ATTENTION_SPLIT_TILE_SIZE + The size of split KV tile in flash decoding Triton + kernel. Used for deterministic inference. + --flashinfer-mla-disable-ragged + Not using ragged prefill wrapper when running + flashinfer mla + --enable-fused-qk-norm-rope + Enable fused qk normalization and rope rotary + embedding. + --enable-precise-embedding-interpolation + Enable corner alignment for resize of embeddings grid + to ensure more accurate(but slower) evaluation of + interpolated embedding values. + --enable-fused-moe-sum-all-reduce + Enable fused moe triton and sum all reduce. + --enable-deepseek-v4-fp4-indexer + Enable the experimental FP4 C4 indexer path for + DeepSeek V4. Default keeps the existing indexer + implementation. + --disable-custom-all-reduce + Disable the custom all-reduce kernel and fall back to + NCCL. + --enable-mscclpp Enable MSCCL++ for tuned AllReduce and AllGather + messages, with NCCL fallback. + --enable-torch-symm-mem + Enable using torch symm mem for all-reduce kernel and + fall back to NCCL. Only supports CUDA device SM90 and + above. SM90 supports world size 4, 6, 8. SM100 + supports world size 6, 8. + --enable-scattered-sconv + Inkling: replace the attention/MLP output all-reduce + with a hidden-dimension reduce-scatter, run the + channelwise output short convolution on the [T, H/P] + shard, then all-gather before the residual add. This + shards the convolution cache across tensor-parallel + ranks without changing communication volume. + --pre-warm-nccl Pre-warm NCCL/RCCL communicators during startup to + reduce P99 TTFT cold-start latency. Default: enabled + for AMD/HIP (RCCL), disabled for NVIDIA/CUDA (NCCL). + --boundary-reduction {auto,ar,rs,rsv,rs+rsv} + Select FFN boundary reduction: ar uses all-reduce then + token redistribution; rs and rsv permit fixed-size and + variable-size reduce-scatter respectively; rs+rsv + permits both (RSv first). Unsupported paths fall back + to ar. The option applies to the FFN stages of + decoders built with stage boundaries, where auto + resolves the model default; other models ignore it. + Required attention and MoE collectives and all-reduce + fusion are unaffected. + --enable-quant-communications + Enable INT8 quantization of TP communications (limited + support). + --enforce-disable-flashinfer-allreduce-fusion + Enforce disable FlashInfer allreduce fusion. + --flashinfer-allreduce-fusion-backend {auto,trtllm,mnnvl,cutedsl} + Enable FlashInfer allreduce fusion and choose backend. + Requires SM90 or SM10X NVIDIA GPUs. Defaults to auto. + 'auto': choose mnnvl on Blackwell (SM100/SM103) + systems (single- and multi-node) and trtllm on SM90 + single-node systems. 'trtllm': available on single- + node systems only. 'mnnvl': available on SM90 single- + node systems and SM100/SM103 single-node or multi-node + systems via MNNVL fabric. 'cutedsl': Blackwell-only + bf16 MNNVL CuTe DSL backend; also fuses the MoE + finalize and the shared-expert add into the collective + when the MoE runner can defer them. Fuses allreduce + with Residual + RMSNorm for supported MoE models. + --enable-aiter-allreduce-fusion + Enable Aiter AllReduce Fusion. + --enable-torch-compile + Optimize the model with torch.compile. Experimental + feature. + --enable-torch-compile-debug-mode + Enable debug mode for torch compile + --torch-compile-max-bs TORCH_COMPILE_MAX_BS + Set the maximum batch size when using torch compile. + --speculative-algorithm SPECULATIVE_ALGORITHM + Speculative algorithm. Builtins: EAGLE, EAGLE3, NEXTN, + STANDALONE, NGRAM, DFLASH, DSPARK, UNO. Or any name + registered via `SpeculativeAlgorithm.register`. + --uno-lora-path UNO_LORA_PATH + Path to the UNO draft LoRA checkpoint. + --speculative-draft-model-path SPECULATIVE_DRAFT_MODEL_PATH, --speculative-draft-model SPECULATIVE_DRAFT_MODEL_PATH + The path of the draft model weights. This can be a + local folder or a Hugging Face repo ID. + --speculative-draft-model-revision SPECULATIVE_DRAFT_MODEL_REVISION + The specific draft model version to use. It can be a + branch name, a tag name, or a commit id. If + unspecified, will use the default version. + --speculative-draft-load-format {auto,pt,safetensors,instanttensor,npcache,dummy,sharded_state,presharded,gguf,expert_pack,bitsandbytes,mistral,layered,flash_rl,remote,remote_instance,fastsafetensors,private,runai_streamer} + The format of the draft model weights to load. If not + specified, will use the same format as --load-format. + Use 'dummy' to initialize draft model weights with + random values for profiling. + --speculative-num-steps SPECULATIVE_NUM_STEPS + The number of steps sampled from draft model in + Speculative Decoding. + --speculative-eagle-topk SPECULATIVE_EAGLE_TOPK + The number of tokens sampled from the draft model in + eagle2 each step. + --speculative-num-draft-tokens SPECULATIVE_NUM_DRAFT_TOKENS + The number of tokens sampled from the draft model in + Speculative Decoding. + --speculative-dflash-block-size SPECULATIVE_DFLASH_BLOCK_SIZE + DFLASH only. Block size (verify window length). Alias + of --speculative-num-draft-tokens for DFLASH. + --speculative-dspark-block-size SPECULATIVE_DSPARK_BLOCK_SIZE + DSPARK only. Draft block size gamma (number of + proposed draft tokens). The verify window is gamma + + 1, so this sets --speculative-num-draft-tokens = gamma + + 1. Omit to auto-infer gamma from the draft + checkpoint block_size. + --speculative-dspark-sps-table-path SPECULATIVE_DSPARK_SPS_TABLE_PATH + DSPARK only. Path to a pre-profiled SPS cost table + (JSON) built offline with + sglang.benchmark.dspark_sps_profiler, consumed by the + ragged-verify scheduler (cap-accept / compact). Omit + for an uninitialized flat constant-SPS table: the + budget degenerates to verify-all (zero throughput gain + by itself). + --speculative-dspark-confidence-sts-path SPECULATIVE_DSPARK_CONFIDENCE_STS_PATH + DSPARK only. Optional path to a per-position STS + (sequential temperature scaling) calibration JSON, fit + offline with sglang.benchmark.dspark_sts_fit. + Calibrates the confidence-head survival probabilities + the ragged-verify scheduler consumes. Omit to use + identity (no calibration); losslessness is unaffected + either way. + --speculative-dspark-align-verify-tokens-to-graph-tier + DSPARK compact ragged-verify only. Fill the per- + request verify lengths so the total verify-token count + reaches the cuda-graph tier the forward is already + padded to: round the dp-max scheduled total up to the + captured token bucket and let the top-k allocator + admit that many real draft tokens (confidence- + ordered). This recovers the padding the forward pays + for anyway -- both the cuda-graph bucket round-up and + the dp cross-rank max -- turning it into extra real + verification at the same step time. Off by default; + when off the schedule is byte-for-byte unchanged. + --speculative-accept-threshold-single SPECULATIVE_ACCEPT_THRESHOLD_SINGLE + Accept a draft token if its probability in the target + model is greater than this threshold. + --speculative-accept-threshold-acc SPECULATIVE_ACCEPT_THRESHOLD_ACC + The accept probability of a draft token is raised from + its target probability p to min(1, p / threshold_acc). + --speculative-use-rejection-sampling + Use rejection sampling for speculative decoding + (requires topk=1). + --speculative-token-map SPECULATIVE_TOKEN_MAP + The path of the draft model's small vocab table. + --speculative-attention-mode {prefill,decode} + Attention backend for speculative decoding operations + (both target verify and draft extend). Can be one of + 'prefill' (default) or 'decode'. + --speculative-draft-attention-backend SPECULATIVE_DRAFT_ATTENTION_BACKEND + Attention backend for speculative decoding drafting. + --speculative-dsa-topk-backend {sgl-kernel,torch,flashinfer} + DSA indexer top-k backend for speculative draft + workers. Options: 'sgl-kernel', 'torch', 'flashinfer'. + The 'torch' backend currently requires + SGLANG_DSA_FUSE_TOPK=false. + --speculative-draft-kv-cache-dtype {auto,fp8_e5m2,fp8_e4m3,bf16,bfloat16} + KV cache dtype for the speculative draft model only. + The draft pool is allocated with one slot per target + token (draft and target share a slot index space), so + for a small draft it can still rival the target pool: + a 5-layer DFLASH draft costs 10240 bytes/token in + bf16. Setting fp8_e4m3 halves the draft pool; the + saving shows up as free device memory, so raise --mem- + fraction-static to convert it into KV capacity. + Default follows --kv-cache-dtype. + --speculative-draft-window-size SPECULATIVE_DRAFT_WINDOW_SIZE + Sliding window size for the draft model. Honored by + Llama EAGLE-3 (`LlamaForCausalLMEagle3`), DFLASH, and + the built-in EAGLE/MTP draft-decode path on the Triton + and FlashInfer draft backends; other EAGLE-3 backends + (e.g. MLA-based drafters) silently ignore it. For + Llama EAGLE-3, the drafter only attends to the most + recent N keys (verifier hidden states + its own + outputs); the verifier is unaffected. For DFLASH, the + draft worker keeps a recent target-token window in its + local KV cache (paged backends may retain up to one + extra page on the left for alignment). For the built- + in EAGLE/MTP draft, each draft-decode step attends to + a --speculative-draft-sink-size sink plus the most + recent N tokens, leaving the target verify pass + unchanged; it is ignored (with a warning) if the draft + model has a native sliding window of its own. Default + is full attention/context. + --speculative-moe-runner-backend {auto,deep_gemm,triton,triton_kernel,flashinfer_trtllm,experimental_sgl_trtllm,flashinfer_trtllm_routed,flashinfer_cutlass,flashinfer_mxfp4,flashinfer_cutedsl,flashinfer_megamoe,cutlass,aiter,marlin,humming,experimental_sgl_marlin,hpc_ops,megamoe,intel_xpu} + Choose the runner backend for MoE in speculative + decoding. Defaults to --moe-runner-backend, except + flashinfer_megamoe defaults the draft to + flashinfer_trtllm (draft layers are typically + unquantized, which flashinfer_megamoe's global-expert- + id dispatch doesn't support). + --speculative-moe-a2a-backend {none,deepep,mooncake,nixl,mori,ascend_fuseep,flashinfer,megamoe,deepep_v2,pplx,ascend_tp} + Choose the backend for MoE A2A in speculative + decoding. Defaults to --moe-a2a-backend, except + flashinfer_megamoe defaults the draft to none (draft + layers are typically unquantized, which + flashinfer_megamoe's global-expert-id dispatch doesn't + support). + --speculative-enable-w4a4-mxfp4-megamoe, --no-speculative-enable-w4a4-mxfp4-megamoe + Whether the draft model's MXFP4 MegaMoE layers use the + W4A4 mxf4xmxf4 MMA type (see + --enable-w4a4-mxfp4-megamoe). Pass --no-speculative- + enable-w4a4-mxfp4-megamoe to keep the draft on fp8xfp4 + (W4A8) while the target runs W4A4. Same as + --enable-w4a4-mxfp4-megamoe if unset. A draft that + runs MXFP4 MegaMoE with a different MMA type from the + target may allocate an additional MegaMoE symmetric + buffer after the KV pool is sized; lower --mem- + fraction-static if CUDA-graph capture runs out of + memory. + --speculative-draft-model-quantization {awq,fp8,mxfp8,gptq,gptq_marlin,awq_marlin,bitsandbytes,gguf,modelopt,modelopt_fp8,modelopt_fp4,nvfp4_online,modelopt_mixed,petit_nvfp4,w8a8_int8,w8a8_fp8,moe_wna16,w4afp8,mxfp4,auto-round,auto-round-int8,compressed-tensors,modelslim,mxfp_w4a8,quark,quark_int4fp8_moe,quark_mxfp4,mlx_q4,mlx_q8,unquant,humming} + The quantization method for speculative model. + --speculative-skip-dp-mlp-sync + Skip the extra MLP sync that the scheduler performs + before merging a new batch when speculative decoding + + DP attention are both enabled. + --enable-multi-layer-eagle + Enable multi-layer Eagle speculative decoding. + --speculative-adaptive + Enable adaptive speculative decoding that dynamically + adjusts num_steps based on acceptance rate. + --speculative-adaptive-config SPECULATIVE_ADAPTIVE_CONFIG + Path to a JSON config file for adaptive speculative + decoding tuning knobs. + --decoupled-spec-bind-endpoint DECOUPLED_SPEC_BIND_ENDPOINT + ZMQ endpoint this engine binds for its inbound channel + in decoupled speculative decoding (verifier: result + PULL; drafter: control PULL). + --decoupled-spec-connect-endpoints DECOUPLED_SPEC_CONNECT_ENDPOINTS + Peer inbound (bind) endpoints to connect to, ordered + by peer rank, for decoupled speculative decoding. + --decoupled-spec-rank DECOUPLED_SPEC_RANK + This engine's rank within its own role space + (verifier-rank or drafter-rank) for decoupled + speculative decoding. + --decoupled-spec-role {null,verifier,drafter} + Role in decoupled speculative decoding: 'null' + disables it, 'verifier' runs the target/verify half, + 'drafter' runs the draft half. + --spec-trace-dir SPEC_TRACE_DIR + Directory to write decoupled speculative decoding + trace files. + --speculative-ngram-min-bfs-breadth SPECULATIVE_NGRAM_MIN_BFS_BREADTH + The minimum breadth for BFS (Breadth-First Search) in + ngram speculative decoding. + --speculative-ngram-max-bfs-breadth SPECULATIVE_NGRAM_MAX_BFS_BREADTH + The maximum breadth for BFS (Breadth-First Search) in + ngram speculative decoding. + --speculative-ngram-match-type {BFS,PROB} + The match type for cache tree. + --speculative-ngram-max-trie-depth SPECULATIVE_NGRAM_MAX_TRIE_DEPTH + The max trie depth for ngram speculative decoding. + --speculative-ngram-capacity SPECULATIVE_NGRAM_CAPACITY + The cache capacity for ngram speculative decoding. + --speculative-ngram-external-corpus-path SPECULATIVE_NGRAM_EXTERNAL_CORPUS_PATH + Path to an external JSONL corpus to pre-load into SAM + at startup. Additional corpora can be added at runtime + via POST /add_external_corpus. + --speculative-ngram-external-sam-budget SPECULATIVE_NGRAM_EXTERNAL_SAM_BUDGET + Number of draft nodes reserved for the external SAM + subtree in ngram speculative decoding. + --speculative-ngram-external-corpus-max-tokens SPECULATIVE_NGRAM_EXTERNAL_CORPUS_MAX_TOKENS + Fail startup if the tokenized external ngram corpus + exceeds this many tokens. Tune this based on your CPU + memory budget. + --ep-size EP_SIZE, --expert-parallel-size EP_SIZE, --ep EP_SIZE + The expert parallelism size. + --moe-a2a-backend {none,deepep,mooncake,nixl,mori,ascend_fuseep,flashinfer,megamoe,deepep_v2,pplx,ascend_tp,flashinfer_megamoe} + Choose the backend for MoE A2A. + --enable-w4a4-mxfp4-megamoe + Enable the W4A4 MXFP4 MegaMoE path with DeepGEMM's + mxf4xmxf4 MMA type. Use with --moe-a2a-backend + megamoe. + --deepep-v2-mode {direct,hybrid} + DeepEP v2 ElasticBuffer communication topology, fixed + at server init: `direct` (single-node NVLink) or + `hybrid` (multi-node scale-out). Layout/grouped-GEMM + and the decode CUDA graph are chosen per batch by + inference phase, independent of this knob; not + equivalent to DeepEP v1 normal/low_latency. + --moe-runner-backend {auto,deep_gemm,triton,triton_kernel,flashinfer_trtllm,experimental_sgl_trtllm,flashinfer_trtllm_routed,flashinfer_cutlass,flashinfer_mxfp4,flashinfer_cutedsl,flashinfer_megamoe,cutlass,aiter,marlin,humming,experimental_sgl_marlin,hpc_ops,megamoe,intel_xpu} + Choose the runner backend for MoE. + --flashinfer-mxfp4-moe-precision {default,bf16,fp8} + Choose the computation precision of flashinfer mxfp4 + moe. On SM90, `fp8` selects the Humming-style + MXFP4-weight x FP8-activation path introduced by + FlashInfer #3738 and requires FlashInfer >= 0.6.18. + --deepep-mode {auto,normal,low_latency} + Select the mode when enable DeepEP or MoriEP MoE, + could be `normal`, `low_latency` or `auto`. Default is + `auto`, which means `low_latency` for decode batch and + `normal` for prefill batch. + --fuseep-mode {1,2} Select the mode when enable Ascend FuseEP MoE, 1 -> + dispatch_gmm_combine_decode is executed;2 -> + dispatch_ffn_combine is executed (support hybrid + deployment when 2). + --deepep-dispatcher-output-dtype {auto,bf16,fp8,int8,nvfp4} + Select DeepEP dispatcher output dtype + --ep-num-redundant-experts EP_NUM_REDUNDANT_EXPERTS + Allocate this number of redundant experts in expert + parallel. + --ep-dispatch-algorithm {static,dynamic,fake,lp} + The algorithm to choose ranks for redundant experts in + expert parallel. + --init-expert-location INIT_EXPERT_LOCATION + Initial location of EP experts. + --enable-eplb Enable EPLB algorithm + --eplb-algorithm EPLB_ALGORITHM + Chosen EPLB algorithm + --eplb-rebalance-num-iterations EPLB_REBALANCE_NUM_ITERATIONS + Number of iterations to automatically trigger a EPLB + re-balance. + --eplb-rebalance-layers-per-chunk EPLB_REBALANCE_LAYERS_PER_CHUNK + Number of layers to rebalance per forward pass. + --eplb-min-rebalancing-utilization-threshold EPLB_MIN_REBALANCING_UTILIZATION_THRESHOLD + Minimum threshold for GPU average utilization to + trigger EPLB rebalancing. Must be in the range [0.0, + 1.0]. + --expert-distribution-recorder-mode {stat,stat_approx,per_pass,per_token} + Mode of expert distribution recorder. + --expert-distribution-recorder-buffer-size EXPERT_DISTRIBUTION_RECORDER_BUFFER_SIZE + Circular buffer size of expert distribution recorder. + Set to -1 to denote infinite buffer. + --expert-balancedness-report-mode {off,server_log,prometheus,both} + Where to report expert balancedness. Options: off, + server_log, prometheus, both. + --deepep-config DEEPEP_CONFIG + Tuned DeepEP config suitable for your own cluster. It + can be either a string with JSON content or a file + path. + --moe-dense-tp-size MOE_DENSE_TP_SIZE + TP size for MoE dense MLP layers. This flag is useful + when, with large TP size, there are errors caused by + weights in MLP layers having dimension smaller than + the min dimension GEMM supports. + --elastic-ep-backend {none,mooncake,nixl} + Specify the collective communication backend for + elastic EP. Supports 'mooncake' and 'nixl'. + --enable-elastic-expert-backup + Enable elastic expert backup feature. + --mooncake-ib-device MOONCAKE_IB_DEVICE + The InfiniBand devices for Mooncake Backend transfer, + accepts multiple comma-separated devices (e.g., + --mooncake-ib-device mlx5_0,mlx5_1). Default is None, + which triggers automatic device detection when + Mooncake Backend is enabled. + --enable-waterfill Enable Waterfill: dispatch the fused shared expert as + an extra routed expert slot to the least-loaded EP + rank. Supports DeepEP and MegaMOE MoE A2A backends, + implicitly enables shared-expert fusion, and supports + --deepep-mode auto, normal, or low_latency when used + with DeepEP. Use auto or low_latency for production + DeepEP decode so CUDA graph remains enabled. Supported + on DeepSeek-V3/R1 with EP >= 2. + --elastic-ep-join-mode {scale,recover} + Join mode for elastic EP. 'recover' rejoins an + existing slot after a fault. 'scale' joins as a new + rank beyond the original group size and requires + --node-rank 1. + --elastic-ep-join-rank-offset EP_JOIN_RANK_OFFSET + Global rank offset of an elastic EP joining group. + Scale joiners must set this to the current effective + EP size. + --elastic-ep-initial-size ELASTIC_EP_INITIAL_SIZE + EP size used to define the immutable per-rank expert + storage layout. Scale joiners must use the primary + deployment's launch-time EP size. + --max-ep-size MAX_EP_SIZE + Maximum EP size the server can scale to at runtime. + Pre-allocates active-rank state and backend buffers to + this size. Defaults to the launch-time world size. + --elastic-ep-scale-timeout ELASTIC_EP_SCALE_TIMEOUT + Timeout in seconds for a pending elastic EP scale + operation. + --disable-flashinfer-cutlass-moe-fp4-allgather + Disables quantize before all-gather for flashinfer + cutlass moe. + --disable-shared-experts-fusion + Disable the built-in shared experts fusion + optimization for DeepSeek V3/R1. Note: Waterfill + (--enable-waterfill) routes the shared expert as an + extra MoE slot, so the shared expert is not separated + from the MoE path when Waterfill is enabled. + --enforce-shared-experts-fusion + Enforce shared experts fusion even when it would + normally be disabled (e.g. under DeepEP). Mutually + exclusive with --disable-shared-experts-fusion. + --max-mamba-cache-size MAX_MAMBA_CACHE_SIZE + The maximum size of the mamba cache. + --mamba-ssm-dtype {float32,bfloat16,float16} + The data type of the SSM states in mamba cache. If not + set, will be read from model config (mamba_ssm_dtype). + --mamba-max-states-per-path MAMBA_MAX_STATES_PER_PATH + Maximum number of cached Mamba states retained per + root-to-tail path (-1 means unlimited). When enabled, + after each insert the shallowest eligible interior + states beyond the cap are removed while their full KV + remains. Tail, fork, and locked nodes are preserved. + Must be -1 or a positive integer. + --enable-mamba-cache-stochastic-rounding + Enable stochastic rounding when writing FP16 Mamba SSM + cache states. Requires --mamba-ssm-dtype float16 and + CUDA. With --mamba-backend triton, requires SM100. + --mamba-cache-philox-rounds MAMBA_CACHE_PHILOX_ROUNDS + Number of Philox rounds to use for stochastic rounding + of FP16 Mamba SSM cache writes. Triton uses the Triton + default when set to 0; FlashInfer uses 10 rounds when + set to 0. + --mamba-full-memory-ratio MAMBA_FULL_MEMORY_RATIO + The ratio of mamba state memory to full kv cache + memory. + --mamba-radix-cache-strategy {auto,no_buffer,extra_buffer,extra_buffer_lazy} + The strategy to use for mamba radix cache. + --mamba-track-interval MAMBA_TRACK_INTERVAL + The interval to track the mamba state during decode. + --enable-int8-mamba-checkpoint + Store radix-cached linear-attn (mamba) states in int8 + (separate checkpoint pool) for ~2x cached-prefix + capacity at fixed memory. + --int8-mamba-ckpt-size INT8_MAMBA_CKPT_SIZE + Number of int8 mamba checkpoint slots (default: 2x the + active mamba pool size). + --linear-attn-backend {triton,cutedsl,flashinfer,flashkda,nvidia_kda,ptx_kda,helion,intel_xpu} + The default kernel backend for linear attention + (GDN/KDA). Can be overridden per-mode by --linear- + attn-decode-backend and --linear-attn-prefill-backend. + The Helion backend is KDA-only. + --linear-attn-decode-backend {triton,cutedsl,flashinfer,flashkda,nvidia_kda,ptx_kda,helion,intel_xpu} + Override the kernel backend for linear attention + decode. If not set, uses --linear-attn-backend. + --linear-attn-prefill-backend {triton,cutedsl,flashinfer,flashkda,nvidia_kda,ptx_kda,helion,intel_xpu} + Override the kernel backend for linear attention + prefill/extend. If not set, uses --linear-attn- + backend; compatible SM100 GDN models may automatically + select FlashInfer. + --linear-attn-verify-backend {triton,cutedsl,flashinfer,flashkda,nvidia_kda,ptx_kda,helion,intel_xpu,nv_cutedsl} + Override the kernel backend for linear attention + speculative target-verify. If not set, follows the + decode backend (flashinfer decode -> flashinfer + verify, otherwise triton). KDA supports triton, + nv_cutedsl, and flashinfer verify backends. + --enable-linear-replayssm + Enable the ReplaySSM buffered output-only linear-attn + decode kernel. Primarily a GDN (scalar-gate) decode- + bandwidth optimization (~1.2-1.5x at batch >= 64). KDA + uses its selected Triton or Helion implementation, but + its per-K gate ring is larger and ReplaySSM is + typically slower than packed KDA decode; benchmark + before enabling it. Requires the Triton linear-attn + decode backend, or Helion for KDA, and --mamba-radix- + cache-strategy no_buffer (the default). + --linear-replayssm-cache-len LINEAR_REPLAYSSM_CACHE_LEN + Ring-buffer length L for ReplaySSM linear-attn decode. + The full recurrent state is flushed to HBM every L + decode steps. + --enable-linear-replayssm-spec + Enable the ReplaySSM spec-verify: fold-every-commit -- + a per-slot raw-input window replaces the recurrent + verify's per-draft full-state snapshots. GDN or KDA + hybrid linear-attn models, linear-chain + (--speculative-eagle-topk in {None, 1}) only. + --enable-hierarchical-cache + Enable hierarchical cache + --hicache-host-memory-mode {cache,buffer_only} + Whether host memory is a persistent HiCache tier + (cache) or a transient staging buffer between GPU and + the storage backend (buffer_only). buffer_only + requires --hicache-storage-backend. + --hicache-ratio HICACHE_RATIO + The ratio of the size of host KV cache memory pool to + the size of device pool. Defaults to 2.0 in cache + mode, 1.2 in buffer_only mode, or 0.2 for backup-only + host-pool decode retraction. + --hicache-size HICACHE_SIZE + The size of host KV cache memory pool in gigabytes. + Overrides --hicache-ratio in either host memory mode. + --hicache-write-policy {write_back,write_through,write_through_selective} + The write policy of hierarchical cache. + --hicache-io-backend {direct,kernel,kernel_ascend} + The IO backend for KV cache transfer between CPU and + GPU + --hicache-mem-layout {layer_first,page_first,page_first_direct,page_first_kv_split,page_head} + The layout of host memory pool for hierarchical cache. + --hicache-storage-backend {file,sim,mooncake,npu_memcache,hf3fs,nixl,aibrix,dynamic,eic,simm,mori,shm,tensorcast,seaweedfs} + The storage backend for hierarchical KV cache. Built- + in backends: file, mooncake, npu_memcache, hf3fs, + nixl, aibrix, tensorcast, seaweedfs. For dynamic + backend, use --hicache-storage-backend-extra-config to + specify: backend_name (custom name), module_path + (Python module path), class_name (backend class name). + --hicache-storage-prefetch-policy {best_effort,wait_complete,timeout} + Control when prefetching from the storage backend + should stop. + --hicache-storage-backend-extra-config HICACHE_STORAGE_BACKEND_EXTRA_CONFIG + A dictionary in JSON string format, or a string + starting with a leading '@' and a config file in + JSON/YAML/TOML format, containing extra configuration + for the storage backend. + --hicache-storage-prefetch-retry-poll-interval HICACHE_STORAGE_PREFETCH_RETRY_POLL_INTERVAL + Scheduling passes a queued request waits before its + storage availability check is re-issued, when the + prefetch found nothing and a backup may still be + committing (under load the first check can run before + it does). A re-issue that waits on staging or a moved + match instead goes out on the next pass. Only passes + that reach prefill scheduling count. 0 disables miss + retries; known-hit deferrals are always re-issued. + --hicache-storage-prefetch-retry-max-attempts HICACHE_STORAGE_PREFETCH_RETRY_MAX_ATTEMPTS + Storage availability re-issues a queued request may + make, paced miss polls and immediate re-issues alike; + past the cap it is admitted with whatever the device + holds. 0 disables re-issues. + --enable-unified-cache-external-linker + Link UnifiedRadixCache directly to an external KV + store (direct L3), with no host cache tier. + --unified-cache-external-linker-backend {mooncake,mori} + Storage backend for --enable-unified-cache-external- + linker. + --enable-hisparse Enable hierarchical sparse attention + --hisparse-config HISPARSE_CONFIG, --hierarchical-sparse-attention-extra-config HISPARSE_CONFIG + A dictionary in JSON string format for hierarchical + sparse attention configuration. Example: '{"top_k": + 2048, "device_buffer_size": 4096, + "host_to_device_ratio": 2}' + --enable-broadcast-mm-inputs-process + Enable broadcast mm-inputs process in scheduler. + --enable-prefix-mm-cache + Enable prefix multimodal cache. Currently only + supports mm-only. + --mm-enable-dp-encoder + Enabling data parallelism for mm encoder. The dp size + will be set to the tp size automatically. + --mm-process-config MM_PROCESS_CONFIG + Multimodal preprocessing config, a json config + contains keys: `image`, `video`, `audio` + --mm-processor-worker-num MM_PROCESSOR_WORKER_NUM + Number of threads for multimodal processor calls. 0 + selects the model-specific default. Only processors + with isolated-worker support can use more than one + thread. + --mm-io-worker-num MM_IO_WORKER_NUM + Number of threads for multimodal data loading and + decoding. 0 selects the model-specific default. + SGLANG_IO_WORKERS remains supported as an environment + override when this argument is 0. + --allowed-media-domains ALLOWED_MEDIA_DOMAINS [ALLOWED_MEDIA_DOMAINS ...] + Restrict client-supplied HTTP(S) image, video, and + audio URLs to these exact hostnames. Redirect + destinations are checked against the same allowlist. + When unset, remote media from any domain is allowed. + --media-url-max-file-size-mb MEDIA_URL_MAX_FILE_SIZE_MB + Maximum size in MiB for one client-supplied remote + media download. The limit is enforced while streaming; + set to 0 to disable it. + --mm-preprocess-cache-size-mb MM_PREPROCESS_CACHE_SIZE_MB + CPU memory budget for content-addressed multimodal + preprocessing artifacts. Unset selects a model- + specific default (256 MiB for Kimi-K3); 0 disables the + cache. The budget is divided across tokenizer workers + and does not reserve GPU memory. + --trust-mm-content-hashes + Trust caller-provided multimodal SHA-256 content + hashes. This can skip reading media on a hot metadata- + cache hit; only enable it when the caller guarantees + that hashes identify immutable media bytes. + --limit-mm-data-per-request LIMIT_MM_DATA_PER_REQUEST + Limit the number of multimodal inputs per request. + e.g. '{"image": 1, "video": 1, "audio": 1}' + --enable-mm-global-cache + Enable global multimodal embedding cache to skip + redundant ViT inference. + --image-processor-backend {auto,torchvision,pil} + Image processor backend. 'auto' lets Transformers + select the best available backend. + --mm-global-cache-backend {mooncake} + Storage backend for the multimodal global embedding + cache. Used when --enable-mm-global-cache is set. + --disable-fast-image-processor + Deprecated. Use --image-processor-backend=pil instead. + --mm-feature-transport {cpu,cuda_ipc,cuda_vmm} + Transport multimodal features through CPU memory, a + bounded CUDA IPC pool, or a bounded CUDA VMM pool. + Unset uses cpu except for validated multi-node + GB200/GB300 MNNVL models, which use cuda_vmm when an + IMEX channel is available. Select cuda_ipc explicitly + for single-node GPU transport. GPU transports reserve + SGLANG_MM_FEATURE_CACHE_MB (default 1024 MiB) on the + base GPU and fall back to CPU transport when the pool + is full. + --enable-lora Enable LoRA support for the model. This argument is + automatically set to True if `--lora-paths` is + provided for backward compatibility. + --enable-lora-overlap-loading + Enable asynchronous LoRA weight loading in order to + overlap H2D transfers with GPU compute. This should be + enabled if you find that your LoRA workloads are + bottlenecked by adapter weight loading, for example + when frequently loading large LoRA adapters. + --max-lora-rank MAX_LORA_RANK + The maximum rank of LoRA adapters. If not specified, + it will be automatically inferred from the adapters + provided in --lora-paths. + --lora-target-modules [{q_proj,k_proj,v_proj,o_proj,q_a_proj,kv_a_proj_with_mqa,q_b_proj,kv_b_proj,wq_b,wk,weights_proj,gate_proj,up_proj,down_proj,qkv_proj,gate_up_proj,embed_tokens,lm_head,qkvr,wo_ud,all} ...] + The union set of all target modules where LoRA should + be applied. If not specified, it will be automatically + inferred from the adapters provided in --lora-paths. + If 'all' is specified, all supported modules will be + targeted. + --lora-paths [LORA_PATHS ...] + The list of LoRA adapters to load. Each adapter must + be specified in one of the following formats: <PATH> | + <NAME>=<PATH> | JSON with schema + {"lora_name":str,"lora_path":str,"pinned":bool} + --max-loaded-loras MAX_LOADED_LORAS + If specified, it limits the maximum number of LoRA + adapters loaded in CPU memory at a time. The value + must be greater than or equal to `--max-loras-per- + batch`. + --max-loras-per-batch MAX_LORAS_PER_BATCH + Maximum number of adapters for a running batch, + include base-only request. + --lora-eviction-policy {lru,fifo} + LoRA adapter eviction policy when memory pool is full. + 'lru': Least Recently Used (default, better cache + efficiency). 'fifo': First-In-First-Out. + --lora-backend {triton,csgmv,ascend,torch_native} + Choose the kernel backend for multi-LoRA serving. + --max-lora-chunk-size {16,32,64,128} + Maximum chunk size for the ChunkedSGMV LoRA backend. + Only used when --lora-backend is 'csgmv'. Choosing a + larger value might improve performance. + --experts-shared-outer-loras, --no-experts-shared-outer-loras + Force shared outer LoRA mode for MoE models. When set, + w1/w3 lora_A and w2 lora_B are shared across experts + (expert_dim=1). Use --no-experts-shared-outer-loras to + force disable. By default this is auto-detected from + adapter weights. + --lora-use-virtual-experts + Enable virtual expert computation for MoE models. When + set, the model will use virtual expert computation. + --lora-strict-loading, --no-lora-strict-loading + Enable strict loading for LoRA adapters. When set, + mismatched or missing keys in the adapter weights will + raise an error. + --lora-drain-wait-threshold LORA_DRAIN_WAIT_THRESHOLD + When any LoRA adapter request waits longer than this + threshold (in seconds), the scheduler will selectively + drain one running adapter to make room. This mitigates + extreme tail latency under high or skewed workloads by + preventing a small set of adapters from monopolizing + batch slots. Set to 0 to disable draining (default). + --enable-two-batch-overlap + Enabling two micro batches to overlap. + --enable-single-batch-overlap + Let computation and communication overlap within one + micro batch. + --tbo-token-distribution-threshold TBO_TOKEN_DISTRIBUTION_THRESHOLD + The threshold of token distribution between two + batches in micro-batch-overlap, determines whether to + two-batch-overlap or two-chunk-overlap. Set to 0 + denote disable two-chunk-overlap. + --cpu-offload-gb CPU_OFFLOAD_GB + How many GBs of RAM to reserve for CPU offloading. + --offload-group-size OFFLOAD_GROUP_SIZE + Number of layers per group in offloading. + --offload-num-in-group OFFLOAD_NUM_IN_GROUP + Number of layers to be offloaded within a group. + --offload-prefetch-step OFFLOAD_PREFETCH_STEP + Steps to prefetch in offloading. + --offload-mode OFFLOAD_MODE + Mode of offloading. + --enable-lmcache Using LMCache as an alternative hierarchical cache + solution + --lmcache-config-file LMCACHE_CONFIG_FILE + Path to the LMCache YAML configuration file + --enable-flexkv Route the default RadixCache through FlexKV's + KVManager for host-tier (CPU / SSD / Remote) KV cache + offload. Equivalent to --radix-cache-backend=flexkv + but also participates in the auto-selection chain + alongside --enable-lmcache. + --flexkv-config-file FLEXKV_CONFIG_FILE + Path to the FlexKV YAML / JSON configuration file. + Equivalent to setting the FLEXKV_CONFIG_PATH + environment variable. + --kt-weight-path KT_WEIGHT_PATH + [ktransformers parameter] The path of the quantized + expert weights for amx kernel. A local folder. + --kt-method KT_METHOD + [ktransformers parameter] Quantization formats for CPU + execution. + --kt-cpuinfer KT_CPUINFER + [ktransformers parameter] The number of CPUInfer + threads. + --kt-threadpool-count KT_THREADPOOL_COUNT + [ktransformers parameter] One-to-one with the number + of NUMA nodes (one thread pool per NUMA). + --kt-num-gpu-experts KT_NUM_GPU_EXPERTS + [ktransformers parameter] The number of GPU experts. + --kt-max-deferred-experts-per-token KT_MAX_DEFERRED_EXPERTS_PER_TOKEN + [ktransformers parameter] Maximum number of experts + deferred to CPU per token. All MoE layers except the + final one use this value; the final layer always uses + 0. + --dllm-algorithm DLLM_ALGORITHM + The diffusion LLM algorithm, such as LowConfidence. + --dllm-algorithm-config DLLM_ALGORITHM_CONFIG + The diffusion LLM algorithm configurations. Must be a + YAML file. + --dllm-fdfo, --no-dllm-fdfo + Enable First-Done-First-Out (FDFO) scheduling for + diffusion LLM inference. Enabled by default; use --no- + dllm-fdfo to fall back to synchronous block + scheduling. + --disaggregation-mode {null,prefill,decode} + Only used for PD disaggregation. "prefill" for + prefill-only server, and "decode" for decode-only + server. If not specified, it is not PD disaggregated + --disaggregation-transfer-backend {mooncake,nixl,ascend,fake,mori,mooncake_tcp} + The backend for disaggregation transfer. Default is + mooncake. + --disaggregation-bootstrap-port DISAGGREGATION_BOOTSTRAP_PORT + Bootstrap server port on the prefill server. Default + is 8998. + --disaggregation-ib-device DISAGGREGATION_IB_DEVICE + The InfiniBand devices for disaggregation transfer. + Supports a single device (e.g., --disaggregation-ib- + device mlx5_0), a shared comma-separated list (e.g., + --disaggregation-ib-device mlx5_0,mlx5_1), a per-GPU + JSON mapping (e.g., --disaggregation-ib-device '{"0": + "mlx5_0,mlx5_1", "1": "mlx5_2"}'), or a path to a JSON + file containing that mapping. Default is None, which + triggers automatic device detection when mooncake + backend is enabled. + --disaggregation-decode-enable-radix-cache + Enable radix cache on decode server (PD mode). Caches + KV prefixes to avoid redundant transfers. Incompatible + with --enable-hisparse, speculative decoding, and + --disaggregation-transfer-backend fake. + --disaggregation-decode-enable-offload-kvcache + Enable async KV cache offloading on decode server (PD + mode). + --disaggregation-decode-retraction-backup {cpu_tensor,host_pool,none} + Storage backend for KV preserved across PD decode + retraction. 'cpu_tensor' uses per-request CPU tensors. + 'host_pool' uses a reserved HiCache pool and does not + fall back on exhaustion. 'none' keeps no backup: a + retracted request is aborted with 503 for the client + to retry. If omitted, the backend is inferred from the + decode KV pool. + --num-reserved-decode-tokens NUM_RESERVED_DECODE_TOKENS + Number of decode tokens that will have memory reserved + when adding new request to the running batch. + --disaggregation-decode-extra-slots DISAGGREGATION_DECODE_EXTRA_SLOTS + Number of extra decode req_to_token slots pre- + allocated for in-transfer requests (PD mode). If + unset, defaults to 0 (or 2x the per-worker running + batch for small batches). + --disaggregation-decode-polling-interval DISAGGREGATION_DECODE_POLLING_INTERVAL + The interval to poll requests in decode server. Can be + set to >1 to reduce the overhead of this. + --optimistic-prefill-attempts OPTIMISTIC_PREFILL_ATTEMPTS + Number of optimistic prefill forward passes that skip + the bootstrap wait. + --encoder-only For MLLM with an encoder, launch an encoder-only + server + --language-only For VLM, load weights for the language model only. + --language-model-only + Skip the multimodal encoder entirely: its weights are + never loaded and the tower is never built, freeing + that GPU memory for KV cache. Multimodal requests are + rejected. Unlike --language-only this is a standalone + mode, not part of encoder/decoder disaggregation. + --encoder-transfer-backend {auto,zmq_to_scheduler,zmq_to_tokenizer,mooncake} + The backend for encoder disaggregation transfer. Auto + selects a model- and TP-aware backend. + --encoder-urls ENCODER_URLS [ENCODER_URLS ...] + List of encoder server urls. + --encoder-bootstrap-port ENCODER_BOOTSTRAP_PORT + Port for the EncoderBootstrapServer that runs in the + language-only tokenizer manager process. Encoders + register here, and language-only receivers fetch the + current URL list from here. + --encoder-register-urls ENCODER_REGISTER_URLS [ENCODER_REGISTER_URLS ...] + One or more EncoderBootstrapServer URLs to register + this encoder with on startup, for dynamic encoder + discovery. Example: --encoder-register-urls + http://prefill0:8997 http://prefill1:8997. Used with + --encoder-only servers. + --enable-adaptive-dispatch-to-encoder + When enabled, adaptively dispatch: multi-image + requests go to encoder in language_only epd mode, + single-image requests are processed locally. + --enable-pdmux Enable PD-Multiplexing, PD running on greenctx stream. + --pdmux-config-path PDMUX_CONFIG_PATH + The path of the PD-Multiplexing config file. + --sm-group-num SM_GROUP_NUM + Number of sm partition groups. + --startup-weight-load-mode {serial,overlap} + Control startup weight loading relative to CUDA graph + capture. 'serial' preserves the existing startup + order; 'overlap' stages checkpoint files while CUDA + graphs are captured and commits the real weights + afterward. + --custom-weight-loader [CUSTOM_WEIGHT_LOADER ...] + The custom dataloader which used to update the model. + Should be set with a valid import path, such as + my_package.weight_load_func + --weight-loader-disable-mmap + Disable mmap while loading weight using safetensors. + --weight-loader-prefetch-checkpoints + Prefetch checkpoint files into OS page cache before + loading. Each rank prefetches a fraction of the + shards, reducing total network I/O on shared + filesystems (NFS/Lustre) from N*checkpoint to + 1*checkpoint. Recommended for models on network + storage. When enabled, multi-threaded safetensors + loading is disabled by default to avoid I/O + oversubscription with the prefetch threads; set + enable_multithread_load=true in --model-loader-extra- + config to keep multi-threaded loading (e.g. on local + NVMe where prefetch is a no-op). + --weight-loader-prefetch-num-threads WEIGHT_LOADER_PREFETCH_NUM_THREADS + Number of threads per rank for checkpoint prefetching + (default: 4). + --weight-loader-drop-cache-after-load + Call posix_fadvise(DONTNEED) on each safetensors shard + after loading it. + --remote-instance-weight-loader-seed-instance-ip REMOTE_INSTANCE_WEIGHT_LOADER_SEED_INSTANCE_IP + The ip of the seed instance for loading weights from + remote instance. + --remote-instance-weight-loader-seed-instance-service-port REMOTE_INSTANCE_WEIGHT_LOADER_SEED_INSTANCE_SERVICE_PORT + The service port of the seed instance for loading + weights from remote instance. + --remote-instance-weight-loader-send-weights-group-ports REMOTE_INSTANCE_WEIGHT_LOADER_SEND_WEIGHTS_GROUP_PORTS + The communication group ports for loading weights from + remote instance. + --remote-instance-weight-loader-backend {transfer_engine,nccl,modelexpress} + The backend for loading weights from remote instance. + Can be 'transfer_engine', 'nccl', or 'modelexpress'. + Default is 'nccl'. + --remote-instance-weight-loader-start-seed-via-transfer-engine + Start seed server via transfer engine backend for + remote instance weight loader. + --engine-info-bootstrap-port ENGINE_INFO_BOOTSTRAP_PORT + Port for the engine info bootstrap server. Default is + 6789. Must be set explicitly when running multiple + instances on the same node. + --modelexpress-config MODELEXPRESS_CONFIG + JSON config for ModelExpress P2P weight loading. Keys: + "url" (optional gRPC host:port override), "transport" + ("nixl" or "transfer_engine"). Example: '{"url": + "localhost:8001", "transport": "nixl"}' + --download-dir DOWNLOAD_DIR + Model download directory for huggingface. + --model-checksum [MODEL_CHECKSUM] + Model file integrity verification. If provided without + value, uses model-path as HF repo ID. Otherwise, + provide checksums JSON file path or HuggingFace repo + ID. + --delete-ckpt-after-loading + Delete the model checkpoint after loading the model. + --decrypted-config-file DECRYPTED_CONFIG_FILE + The path of the decrypted config file. + --decrypted-draft-config-file DECRYPTED_DRAFT_CONFIG_FILE + The path of the decrypted draft config file. + --checkpoint-engine-wait-weights-before-ready + If set, the server will wait for initial weights to be + loaded via checkpoint-engine or other update methods + before serving inference requests. + --enable-prefill-delayer + Enable prefill delayer for DP attention to reduce idle + time. + --prefill-delayer-max-delay-passes PREFILL_DELAYER_MAX_DELAY_PASSES + Maximum forward passes to delay prefill. + --prefill-delayer-token-usage-low-watermark PREFILL_DELAYER_TOKEN_USAGE_LOW_WATERMARK + Token usage low watermark for prefill delayer. + --prefill-delayer-forward-passes-buckets PREFILL_DELAYER_FORWARD_PASSES_BUCKETS [PREFILL_DELAYER_FORWARD_PASSES_BUCKETS ...] + Custom buckets for prefill delayer forward passes + histogram. 0 and max_delay_passes-1 will be auto- + added. + --prefill-delayer-wait-seconds-buckets PREFILL_DELAYER_WAIT_SECONDS_BUCKETS [PREFILL_DELAYER_WAIT_SECONDS_BUCKETS ...] + Custom buckets for prefill delayer wait seconds + histogram. 0 will be auto-added. + --prefill-delayer-queue-min-ratio PREFILL_DELAYER_QUEUE_MIN_RATIO + Opt-in to the adaptive queue-based delay trigger + (independent of the slot-based one). Delays prefill + until the waiting queue reaches min(running_req * + ratio, prefill_max_requests), falling back to the + observed max_prefill_bs when no request limit is set. + Unset (default) keeps the original slot-only behavior. + Typical: 0.1 ~ 0.5. + --prefill-delayer-max-delay-ms PREFILL_DELAYER_MAX_DELAY_MS + Wall-clock cap (ms) on a single queue-trigger delay; + once exceeded, prefill is force-released to bound + worst-case TTFT. Only consulted when --prefill- + delayer-queue-min-ratio is set. Typical: 1000 ~ 5000; + defaults to 5000 if unset. + --min-free-slots-delay MIN_FREE_SLOTS_DELAY + Hold new prefills until at least N running-request + slots have freed up, so they are admitted in one batch + instead of one at a time. Useful when each admission + is disproportionately expensive, e.g. speculative + decoding with a separate draft prefill pass. An + explicit value always wins, capped by max-running- + requests (1 disables). When unset, DFlash workloads + auto-enable the formula; other workloads stay + disabled. Not supported with pipeline parallelism. + --enable-deterministic-inference + Enable deterministic inference mode with batch + invariant ops. + --rl-on-policy-target {fsdp} + The training system that SGLang needs to match for + true on-policy. + --kv-canary {none,log,raise} + KV cache canary mode. 'none' disables the canary + (default). 'log' prints them while the server keeps + running (production-safe). 'raise' fails the server on + the first detected mismatch (CI lane). + --kv-canary-sweep-interval KV_CANARY_SWEEP_INTERVAL + Every N forward steps, run a full-pool sweep. + --enable-dynamic-batch-tokenizer + Enable async dynamic batch tokenizer for improved + performance when multiple requests arrive + concurrently. + --dynamic-batch-tokenizer-batch-size DYNAMIC_BATCH_TOKENIZER_BATCH_SIZE + [Only used if --enable-dynamic-batch-tokenizer is set] + Maximum batch size for dynamic batch tokenizer. + --dynamic-batch-tokenizer-batch-timeout DYNAMIC_BATCH_TOKENIZER_BATCH_TIMEOUT + [Only used if --enable-dynamic-batch-tokenizer is set] + Timeout in seconds for batching tokenization requests. + --enable-tokenizer-batch-encode + Enable batch tokenization for improved performance + when processing multiple text inputs. Do not use with + image inputs, pre-tokenized input_ids, or + input_embeds. + --disable-tokenizer-batch-decode + Disable batch decoding when decoding multiple + completions. + --debug-tensor-dump-output-folder DEBUG_TENSOR_DUMP_OUTPUT_FOLDER + The output folder for dumping tensors. In Eagle mode, + tensor outputs from draft and target models are stored + in separate subdirectories ('draft' and 'target'). + --debug-tensor-dump-layers DEBUG_TENSOR_DUMP_LAYERS [DEBUG_TENSOR_DUMP_LAYERS ...] + The layer ids to dump. Dump all layers if not + specified. + --debug-tensor-dump-input-file DEBUG_TENSOR_DUMP_INPUT_FILE + The input filename for dumping tensors + --enable-memory-saver + Allow saving memory using release_memory_occupation + and resume_memory_occupation + --enable-weights-cpu-backup + Save model weights (both main model and draft model, + if any) to CPU memory during + release_weights_occupation and + resume_weights_occupation + --enable-draft-weights-cpu-backup + Save draft model weights to CPU memory during + release_weights_occupation and + resume_weights_occupation + --enable-custom-logit-processor + Enable users to pass custom logit processors to the + server (disabled by default for security) + --enable-return-hidden-states + Enable returning full hidden states with responses. + Equivalent to `--return-hidden-states-mode full`. + --return-hidden-states-mode {last,full} + Set the maximum hidden-state return mode supported by + the server. `last` allows requests with + return_hidden_states=False or `last`; `full` also + allows return_hidden_states=True. + --enable-return-routed-experts + Enable returning routed experts of each layer with + responses. + --enable-return-indexer-topk + Enable returning indexer topk indices of layers with + indexer with responses. + --sampling-mask-max-tokens SAMPLING_MASK_MAX_TOKENS + The maximum number of token IDs in a returned sampling + mask. Requests are aborted if their realized sampling + support exceeds this limit. Use the same value on + disaggregated prefill and decode nodes; clients should + set top_k below the limit to leave headroom for cutoff + ties. + --disable-outlines-disk-cache + Disable disk cache of outlines to avoid possible + crashes related to file system or high concurrency. + --enable-mis Enable Multi-Item Scoring optimization. Combines query + and multiple items into a single sequence for + efficient batch processing. Requires --attention- + backend flashinfer; auto-disables CUDA graph, radix + cache, and chunked prefill. + --weight-cache-mode {off,daemon,client} + Weight cache mode. 'off': normal disk loading. + 'daemon': launch weight cache daemon (holds weights in + GPU memory). Engine-spawned daemons are co-terminal + with the engine and do NOT persist across restarts, so + this alone does not speed up restart (the first start + is slower). For fast recovery, run the standalone + daemon (python -m sglang.srt.weight_cache.daemon) and + connect with 'client'. 'client': connect to existing + daemon and load via IPC. + --weight-cache-socket WEIGHT_CACHE_SOCKET + Unix socket path for weight cache daemon (client + mode).If not set, derives the path from + SGLANG_WEIGHT_CACHE_SOCKET_TEMPLATE using the caller's + physical GPU UUID. + --weight-cache-timeout WEIGHT_CACHE_TIMEOUT + Timeout in seconds for weight cache daemon readiness + (default: 1800). + --forward-hooks FORWARD_HOOKS + JSON-formatted forward hook specifications to attach + to the model. + --msprobe-dump-config MSPROBE_DUMP_CONFIG + The path of the JSON configuration file for msProbe. + If specified, enables msProbe dump. + --ple-offload-embedding, --no-ple-offload-embedding + Offload Qwen4 PLE n-gram embedding weights to CPU + pinned memory. Enabled by default for BF16 Qwen4-Exp + on CUDA; use --no-ple-offload-embedding to disable. + --ple-offload-backend {pinned,file} + Host storage for the offloaded Qwen4 PLE n-gram table. + 'pinned' (default) uses CPU pinned memory. 'file' maps + a sparse file under --ple-offload-dir and lets the + gather kernel read it directly; use it on unified- + memory devices (e.g. GB10 / DGX Spark) where pinned + host memory comes out of the same pool as the model + weights. Requires a device that reports + cudaDevAttrPageableMemoryAccessUsesHostPageTables. + --ple-offload-dir PLE_OFFLOAD_DIR + Directory for the file-backed PLE table when --ple- + offload-backend is 'file'. Defaults to + $SGLANG_CACHE_DIR/ple/<model path>, one directory per + checkpoint. The file is sparse and reused across + restarts; put it on fast local storage (NVMe). + --disaggregation-enable-kv-checksum + Compute an Adler-32 checksum over each request's KV + pages on prefill and verify it on decode. Enable on + both prefill and decode engines. A mismatch aborts the + request, or raises in CI. Disabled by default. + --enable-pd-role-switch + Allow runtime prefill<->decode role switch via + /pd_role_switch (PD mode). + --speculative-domino-candidate-pool-size SPECULATIVE_DOMINO_CANDIDATE_POOL_SIZE + Domino only. Size of the approximate block-shared + base-logit candidate pool. Set to 0 to score the full + vocabulary. + --flashinfer-a2a-dispatch-type {auto,bf16,nvfp4,mxfp8} + Select FlashInfer A2A dispatcher activation dtype. + --cuda-graph-max-seq-len-prefill CUDA_GRAPH_MAX_SEQ_LEN_PREFILL + Longest sequence a prefill cuda graph replay admits; + longer batches run eager prefill. Folds into + cuda_graph_config[prefill].max_seq_len. + --prefill-kv-cache-dequant-dtype {auto,nvfp4,fp8_e4m3} + Online dequantization dtype used by prefill attention + when --kv-cache-dtype=nvfp4. 'nvfp4' reads the packed + cache directly without additional dequantization; + 'fp8_e4m3' dequantizes it into a temporary FP8 + workspace. This does not change the stored KV-cache + dtype. 'auto' selects NVFP4 without additional + dequantization on SM100 and FP8 E4M3 otherwise. + --dsv4-attn-backend {auto,flashmla,trtllm} + DeepSeek V4 attention backend. 'auto' (default) + resolves to 'flashmla'. 'trtllm' (opt-in, SM100/SM103 + with FP8 KV cache) switches the SWA/compressed KV + pools to a uniform 512-dim FP8 layout and runs decode + and sparse prefill through the flashinfer trtllm-gen + sparse MLA kernel. The backend choice is shared by + prefill and decode. + --otlp-service-name OTLP_SERVICE_NAME + Service name for OTLP traces (displayed as + 'service.name' in trace backends). If unset, falls + back to the OTEL_SERVICE_NAME env var, then to + 'sglang'. + --hicache-host-memory-fraction HICACHE_HOST_MEMORY_FRACTION + Fraction of the available host memory, bounded by + visible cgroup memory.max/memory.high or v1 memory + limits (after a 10 GiB reserve) that the HiCache host + pools of all ranks on this machine may use. Applies + only when neither --hicache-ratio nor --hicache-size + is set: the default ratio is then reduced until the + pools fit. Lower it when several engines share a + memory cgroup. + --enable-linker-mla-dedup + Load replicated MLA KV on rank 0 and broadcast each + layer with the Mooncake linker. + --swa-prefix-tails SWA_PREFIX_TAILS + When the SWA KV pool is sized from the request cap + (DeepSeek-V4 family), how many radix-cached prefix + tails it keeps room for. Each tail is one sliding + window plus one page. Default: 4 x + max_running_requests per attention-DP rank, 0 when the + radix cache is disabled. + --enable-encoder-swa-bounded-replay + DeepSeek-V4.1 encoder SWA bounded replay: cache Main + KV and Indexer keys only, rebuild request-owned SWA + windows on prefix hits. Experimental; CUDA and gfx950. + --enable-decoder-swa-bounded-replay + DeepSeek-V4.1 decoder SWA bounded replay: after the + last kv_source layer, run the remaining layers over + only the last window_size tokens of a prefill. Main + and indexer KV stay exact; nothing is replayed. + Deterministic for a fixed prompt and chunk size. + --enable-response-store + Enable in-memory Responses storage for retrieval, + chaining, and background requests. Disabled by + default; unsupported with prefill-decode + disaggregation. Storage has no TTL or size limit. + --disaggregation-decode-host-receive-threshold DISAGGREGATION_DECODE_HOST_RECEIVE_THRESHOLD + Device token usage fraction at which incoming KV is + received in the decode retraction host pool, excluding + evictable cache pages. Range [0, 1]; 1 disables host + receive (default), 0 always uses host receive. Size + with --hicache-size or --hicache-ratio; requires dense + NHD MHA buffers (including separate paged SWA pools) + and a transfer backend that supports host + destinations. No built-in backend currently supports + this. + --qsa-indexer-dtype {auto,bfloat16,fp8_e4m3} + Storage dtype of the compressed QSA indexer cache and + index query (Qwen4-Exp). "fp8_e4m3" stores them as + plain e4m3 and scores blocks with fp8 GEMMs (CUDA + SM90+). The main KV cache is unaffected. + --disaggregation-decode-allocation-policy {early,prefill_complete} + When to reserve per-request decode KV in disaggregated + serving. prefill_complete retains KV on prefill until + prefill finishes, then admits it on decode. Set the + same policy on both roles. Default: early. + --speculative-use-block-verification + Use block verification for EAGLE/EAGLE3/NEXTN + (requires topk=1) or for sampled DFLASH/DSPARK drafts, + on CUDA or ROCm. + --speculative-draft-sink-size SPECULATIVE_DRAFT_SINK_SIZE + Number of leading 'attention sink' tokens the draft + always attends to, in addition to the --speculative- + draft-window-size recent window (StreamingLLM-style). + Honored only by the built-in EAGLE/MTP draft-decode + path on the Triton and FlashInfer draft backends; the + Llama EAGLE-3 and DFLASH windows ignore it. 0/unset => + pure recent window. Requires --speculative-draft- + window-size. + --disable-aiter-allreduce-fusion-in-prefill + Disable Aiter AllReduce Fusion for prefill batches + (EXTEND / MIXED / SPLIT_PREFILL) while keeping it for + decode. Only meaningful with --enable-aiter-allreduce- + fusion. + --disable-aiter-allreduce-fusion-in-decode + Disable Aiter AllReduce Fusion for decode batches + (DECODE / TARGET_VERIFY / draft-extend / IDLE) while + keeping it for prefill. Only meaningful with --enable- + aiter-allreduce-fusion. + --dsa-triton-union {0,2,4} + Union group size for --dsa-prefill-backend + triton_sparse_mla: 0 disables, 2 or 4 make G adjacent + query tokens share one gathered index set + (mathematically exact; an ownership mask restores each + token's own softmax support). + --enable-dsa-fused-indexer, --no-enable-dsa-fused-indexer + Use the four-kernel fused DSA indexer decode path in + place of the 12-launch aiter/torch chain. By default + this is enabled wherever it is supported -- gfx950 + (MI355X) with aiter preshuffle and an fp8 e4m3fn index + cache -- and the runtime declines with a logged reason + wherever any of that is missing. Pass --no-enable-dsa- + fused-indexer to force the standard path. + --enable-watermark Enable Aaronson-Gumbel text watermarking. + --watermark-config WATERMARK_CONFIG + Watermark settings as a JSON file path or an inline + JSON object with optional key, key_b, context_window, + mixing_probability, max_probability, default_enabled, + and enforce_all fields. + --grpc-response-timeout-secs GRPC_RESPONSE_TIMEOUT_SECS + Timeout in seconds waiting for each native gRPC + response chunk, including the first. Must be positive. + Defaults to 300. + --sampling-backend {pytorch,flashinfer,ascend,intel_xpu} + Choose the kernels for sampling layers. + --reasoning-parser {auto,apertus2509,deepseek-r1,deepseek-v3,deepseek-v4,deepseek-v41,dots,glm45,ling3,hunyuan,gpt-oss,k2_horizon,kimi,kimi_k2,kimi_k3,mimo,muse,poolside_v1,qwen3,qwen3-thinking,minimax,minimax-append-think,minimax-m3,nanbeige,step3,step3p5,mistral,nemotron_3,granite_thinking_parser,interns1,gemma4,inkling,iquest_q1,cohere_command4,gigachat35} + Specify the parser for reasoning models. Use 'auto' to + detect from chat template. Options include: + ['apertus2509', 'deepseek-r1', 'deepseek-v3', + 'deepseek-v4', 'deepseek-v41', 'dots', 'glm45', + 'ling3', 'hunyuan', 'gpt-oss', 'k2_horizon', 'kimi', + 'kimi_k2', 'kimi_k3', 'mimo', 'muse', 'poolside_v1', + 'qwen3', 'qwen3-thinking', 'minimax', 'minimax-append- + think', 'minimax-m3', 'nanbeige', 'step3', 'step3p5', + 'mistral', 'nemotron_3', 'granite_thinking_parser', + 'interns1', 'gemma4', 'inkling', 'iquest_q1', + 'cohere_command4', 'gigachat35']. + --tool-call-parser {auto,apertus2509,cohere_command4,deepseekv3,deepseekv31,deepseekv32,deepseekv4,deepseekv41,dots,glm,glm45,glm47,gpt-oss,k2_horizon,kimi_k2,kimi_k3,lfm2,ling3,llama3,mimo,minicpm5,mistral,muse,poolside_v1,pythonic,qwen,qwen25,qwen3_coder,spark25,step3,step3p5,minimax-m2,minimax-m3,nanbeige,trinity,interns1,hermes,hunyuan,gigachat3,gigachat35,gemma4,inkling,iquest_q1} + Specify the parser for handling tool-call + interactions. Use 'auto' to detect from chat template. + Options include: ['apertus2509', 'cohere_command4', + 'deepseekv3', 'deepseekv31', 'deepseekv32', + 'deepseekv4', 'deepseekv41', 'dots', 'glm', 'glm45', + 'glm47', 'gpt-oss', 'k2_horizon', 'kimi_k2', + 'kimi_k3', 'lfm2', 'ling3', 'llama3', 'mimo', + 'minicpm5', 'mistral', 'muse', 'poolside_v1', + 'pythonic', 'qwen', 'qwen25', 'qwen3_coder', + 'spark25', 'step3', 'step3p5', 'minimax-m2', + 'minimax-m3', 'nanbeige', 'trinity', 'interns1', + 'hermes', 'hunyuan', 'gigachat3', 'gigachat35', + 'gemma4', 'inkling', 'iquest_q1']. + --kv-canary-real-data {none,partial,all} + Check the real KV-cache in the canary. 'none' + (default) disables the feature. 'partial' checks the + first 16 bytes of each real-KV slot. 'all' checks the + full real-KV slot. + --config CONFIG Read CLI options from a config file. Must be a YAML + file with configuration options. + --disable-cuda-graph Deprecated. Use --cuda-graph- + backend-{decode,prefill}=disabled instead. + --enable-dp-attention + Deprecated. Use --attn-dp-size <dp-size> instead of + --dp-size <dp-size> --enable-dp-attention. + --enable-flashinfer-allreduce-fusion + (Deprecated: use --flashinfer-allreduce-fusion- + backend=auto) Enable FlashInfer allreduce fusion with + Residual RMSNorm. + --servicer-impl {python,rust} + Which implementation serves the gRPC contract under + --grpc-mode: the Python servicer (default) or the Rust + one (smg.servicer.SglangGrpcServer, with the scheduler + headless). Unset falls back to + $SMG_SGLANG_SERVICER_IMPL. diff --git a/tests/fixtures/canary/help/vllm-0.32.0-cpu-serve-help-all.txt b/tests/fixtures/canary/help/vllm-0.32.0-cpu-serve-help-all.txt new file mode 100644 index 00000000..fbb36c30 --- /dev/null +++ b/tests/fixtures/canary/help/vllm-0.32.0-cpu-serve-help-all.txt @@ -0,0 +1,2392 @@ +usage: vllm serve [model_tag] [options] + +Launch a local OpenAI-compatible API server to serve LLM +completions via HTTP. Defaults to Qwen/Qwen3-0.6B if no model is specified. + +Search by using: `--help=<ConfigGroup>` to explore options by section (e.g., +--help=ModelConfig, --help=Frontend) + Use `--help=all` to show all available flags at once. + +positional arguments: + model_tag The model tag to serve (optional if specified in + config) (default: None) + +options: + --aggregate-engine-logging + Log aggregate rather than per-engine statistics when + using data parallelism. (default: False) + --api-server-count API_SERVER_COUNT, -asc API_SERVER_COUNT + How many API server processes to run. Defaults to + data_parallel_size if not specified. (default: None) + --config CONFIG Read CLI options from a config file. Must be a YAML + with the following options: https://docs.vllm.ai/en/la + test/configuration/serve_args.html (default: None) + --disable-log-stats Disable logging statistics. (default: False) + --enable-log-requests, --no-enable-log-requests + Enable logging request information, dependent on log + level: + - INFO: Request ID, parameters and LoRA request. + - DEBUG: Prompt inputs (e.g: text, token IDs). You can + set the minimum log level via `VLLM_LOGGING_LEVEL`. + (default: False) + --fail-on-environ-validation, --no-fail-on-environ-validation + If set, the engine will raise an error if environment + validation fails. (default: False) + --gdn-prefill-backend {flashinfer,triton,cutedsl,aiter_flydsl} + Select GDN prefill backend. (default: None) + --grpc Launch the Python gRPC (SMG VllmEngine) server on + --port instead of the HTTP OpenAI-compatible server. + Requires: pip install vllm[grpc]. Cannot be combined + with --grpc-port, which enables the Rust frontend's + gRPC services. (default: False) + --headless Run in headless mode. See multi-node data parallel + documentation for more details. (default: False) + --kda-decode-backend {auto,native,flashinfer,triton} + Select KDA decode backend. (default: None) + --kda-prefill-backend {auto,triton,flashkda,flashinfer,fused} + Select KDA prefill backend. 'flashkda' is CUDA-only + and 'fused' is ROCm-only; 'auto' picks a supported + backend. (default: None) + --shutdown-timeout SHUTDOWN_TIMEOUT + Shutdown timeout in seconds. 0 = abort, >0 = wait. + (default: 0) + -h, --help show this help message and exit + +Frontend: + Arguments for the OpenAI-compatible frontend server. + + --allow-credentials, --no-allow-credentials + Allow credentials. (default: False) + --allowed-headers ALLOWED_HEADERS + Allowed headers. (default: ['*']) + --allowed-methods ALLOWED_METHODS + Allowed methods. (default: ['*']) + --allowed-origins ALLOWED_ORIGINS + Allowed origins. Cross-origin browser access is + disabled by default. (default: []) + --anthropic-disabled-thinking-effort {auto,high,low,max,medium,none,xhigh} + Anthropic ``/v1/messages`` only. The + ``reasoning_effort`` used for requests with + ``thinking: {"type": "disabled"}``. ``none`` turns + thinking off for models that support it; ``low`` suits + models that always think (e.g. GLM-5.3) or reject + ``none`` (e.g. gpt-oss). ``auto`` (default) uses + ``low`` when the renderer rejects ``none`` or renders + it the same as a thinking effort, and ``none`` + otherwise. (default: auto) + --api-key API_KEY [API_KEY ...] + If provided, the server will require one of these keys + to be presented in the header. + Warning: this only authenticates endpoints under the + `/v1`, `/v2`, and `/inference` path prefixes. Other + endpoints on the same server, including `/invocations` + (which exposes the same inference capabilities as + `/v1`), remain unauthenticated. Do not rely on `--api- + key` alone to secure vLLM; see https://docs.vllm.ai/en + /latest/usage/security.html#api-key-authentication- + limitations for what it does and does not protect. + (default: None) + --chat-template CHAT_TEMPLATE + The file path to the chat template, or the template in + single-line form for the specified model. (default: + None) + --chat-template-content-format {auto,openai,string} + The format to render message content within a chat + template. + * "string" will render the content as a string. + Example: `"Hello World"` + * "openai" will render the content as a list of + dictionaries, similar to OpenAI schema. Example: + `[{"type": "text", "text": "Hello world!"}]` (default: + auto) + --cohere-format COHERE_FORMAT + Cohere ``--tokenizer-mode cohere`` only. Which Cohere + prompt format to render: ``cmd4`` (current Command A+ + models; default) or ``cmd3`` (earlier Cmd-A and Cmd-A + reasoning models). Selecting the wrong format silently + produces a prompt the model wasn't trained on, which + most commonly manifests as the model emitting text but + no citations / tool calls / thinking blocks. + Equivalent to passing ``--default-chat-template-kwargs + '{"cohere_format": "..."}'`` -- any explicit request- + level ``chat_template_kwargs.cohere_format`` takes + priority. (default: cmd4) + --cohere-is-reasoning-model, --no-cohere-is-reasoning-model + Cohere ``/cohere/v2/chat`` only. Whether the served + model is a reasoning Command-family model. When True + (default), the assistant's chain-of-thought is + surfaced as a ``thinking`` content block (or + ``content-*`` events on the stream). When False, + reasoning is surfaced as Cohere's ``tool_plan`` field + (or ``tool-plan-delta`` events) whenever the model + emits tool calls, matching older non- reasoning + Command models. Has no effect on the non-Cohere + endpoints. (default: True) + --data-parallel-supervisor-port DATA_PARALLEL_SUPERVISOR_PORT + HTTP port for aggregated health endpoints in multi- + port external LB mode. (default: 9256) + --default-chat-template-kwargs DEFAULT_CHAT_TEMPLATE_KWARGS + Default keyword arguments to pass to the chat template + renderer. These will be merged with request-level + chat_template_kwargs, with request values taking + precedence. Useful for setting default behavior for + reasoning models. Example: '{"enable_thinking": + false}' to disable thinking mode by default for + Qwen3/DeepSeek models. + Should either be a valid JSON string or JSON keys + passed individually. (default: None) + --disable-access-log-for-endpoints DISABLE_ACCESS_LOG_FOR_ENDPOINTS + Comma-separated list of endpoint paths to exclude from + uvicorn access logs. This is useful to reduce log + noise from high-frequency endpoints like health + checks. Example: "/health,/metrics,/ping". When set, + access logs for requests to these paths will be + suppressed while keeping logs for other endpoints. + (default: None) + --disable-fastapi-docs, --no-disable-fastapi-docs + Disable FastAPI's OpenAPI schema, Swagger UI, and + ReDoc endpoint. (default: False) + --disable-uvicorn-access-log, --no-disable-uvicorn-access-log + Disable uvicorn access log. (default: False) + --dp-supervisor-probe-failure-threshold DP_SUPERVISOR_PROBE_FAILURE_THRESHOLD + Number of consecutive connection-error retries before + a child health probe is declared failed in multi-port + external LB mode. (default: 3) + --dp-supervisor-probe-interval-s DP_SUPERVISOR_PROBE_INTERVAL_S + Seconds between aggregated health probes in multi-port + external LB mode. (default: 5.0) + --dp-supervisor-probe-timeout-s DP_SUPERVISOR_PROBE_TIMEOUT_S + Seconds to wait between retries when a child health + probe fails with a connection error in multi-port + external LB mode. (default: 5.0) + --enable-auto-tool-choice, --no-enable-auto-tool-choice + Enable auto tool choice for supported models. Use + `--tool-call-parser` to specify which parser to use. + (default: False) + --enable-flash-late-interaction, --no-enable-flash-late-interaction + If set, run pooling score MaxSim on GPU in the API + server process. Can significantly improve late- + interaction scoring performance. When disabled, the + setting is also propagated to the engine's + `PoolerConfig`, so the engine-side scorer uses the + reference MaxSim path instead of the fused Triton + kernel. (default: True) + --enable-force-include-usage, --no-enable-force-include-usage + If set to True, including usage on every request. + (default: False) + --enable-log-deltas, --no-enable-log-deltas + If set to False, output deltas will not be logged. + Relevant only if + --enable-log-outputs is set. (default: True) + --enable-log-outputs, --no-enable-log-outputs + If set to True, log model outputs (generations). + Requires `--enable-log-requests`. Output text and + finish reasons are logged at INFO, while output token + IDs are logged at DEBUG. (default: False) + --enable-offline-docs, --no-enable-offline-docs + Enable offline FastAPI documentation for air-gapped + environments. Uses vendored static assets bundled with + vLLM. (default: False) + --enable-per-request-metrics, --no-enable-per-request-metrics + If set to True, include per-request timing metrics in + API responses. (default: False) + --enable-prompt-tokens-details, --no-enable-prompt-tokens-details + If set to True, enable prompt_tokens_details in usage. + (default: False) + --enable-request-id-headers, --no-enable-request-id-headers + If specified, API server will add X-Request-Id header + to responses. (default: False) + --enable-scale-out, --no-enable-scale-out + If set to True, register the scale-out endpoints + (`/render`, `/derender`, `/inference/v1/generate` and + `/inference/v1/abort_requests`) on `vllm serve`. Has + no effect on `vllm launch render` or `vllm serve + --tokens-only` which always register their required + endpoints regardless of this flag. (default: False) + --enable-server-load-tracking, --no-enable-server-load-tracking + If set to True, enable tracking server_load_metrics in + the app state. (default: False) + --enable-ssl-refresh, --no-enable-ssl-refresh + Refresh SSL Context when SSL certificate files change + (default: False) + --enable-tokenizer-info-endpoint, --no-enable-tokenizer-info-endpoint + Enable the `/tokenizer_info` endpoint. May expose chat + templates and other tokenizer configuration. (default: + False) + --exclude-tools-when-tool-choice-none, --no-exclude-tools-when-tool-choice-none + If specified, exclude tool definitions in prompts when + tool_choice='none'. (default: False) + --fingerprint-mode {custom,full,hash,none} + Controls the ``system_fingerprint`` field on + responses. + - ``full`` (default): + ``vllm-<version>[-<parallelism>]-<hash8>``. Encodes + server version, non-trivial parallelism degrees + (tp/pp/dp/ep), and an 8-char config hash. + - ``hash``: ``vllm-<version>-<hash8>``. Parallelism + stripped. + - ``custom``: emits the literal string from + ``--fingerprint-value``. + - ``none``: the field is omitted (serialized as + ``null``). (default: full) + --fingerprint-value FINGERPRINT_VALUE + Literal fingerprint string used when ``--fingerprint- + mode=custom``. (default: None) + --grpc-port GRPC_PORT + Enable the Rust frontend's additional gRPC Inference + and Control services on this port. Requires + `VLLM_USE_RUST_FRONTEND=1 vllm serve`; HTTP remains on + `--port`. Binds on `--host`, or 127.0.0.1 with + `--uds`. Cannot be combined with the Python gRPC + server's `--grpc` flag. (default: None) + --h11-max-header-count H11_MAX_HEADER_COUNT + Maximum number of HTTP headers allowed in a request + for h11 parser. Helps mitigate header abuse. Default: + 256. (default: 256) + --h11-max-incomplete-event-size H11_MAX_INCOMPLETE_EVENT_SIZE + Maximum size (bytes) of an incomplete HTTP event + (header or body) for h11 parser. Helps mitigate header + abuse. Default: 4194304 (4 MB). (default: 4194304) + --host HOST Host name. (default: None) + --log-error-stack, --no-log-error-stack + If set to True, log the stack trace of error responses + (default: False) + --lora-modules LORA_MODULES [LORA_MODULES ...] + LoRA modules configurations in either 'name=path' + format or JSON format or JSON list format. Example + (old format): `'name=path'` Example (new format): + `{"name": "name", "path": "lora_path", + "base_model_name": "id"}` (default: None) + --max-log-len MAX_LOG_LEN + Max number of prompt characters or prompt ID numbers + being printed in log. The default of None means + unlimited. (default: None) + --middleware MIDDLEWARE + Additional ASGI middleware to apply to the app. We + accept multiple + --middleware arguments. The value should be an import + path. If a function is provided, vLLM will add it to + the server using `@app.middleware('http')`. If a class + is provided, vLLM will add it to the server using + `app.add_middleware()`. (default: []) + --port PORT Port number. (default: 8000) + --response-role RESPONSE_ROLE + The role name to return if + `request.add_generation_prompt=true`. (default: + assistant) + --return-tokens-as-token-ids, --no-return-tokens-as-token-ids + When `--max-logprobs` is specified, represents single + tokens as strings of the form 'token_id:{token_id}' so + that tokens that are not JSON-encodable can be + identified. (default: False) + --root-path ROOT_PATH + FastAPI root_path when app is behind a path based + routing proxy. (default: None) + --sse-keep-alive-interval SSE_KEEP_ALIVE_INTERVAL + Send an SSE keep-alive comment line every this many + seconds when a `/v1/chat/completions` or + `/v1/completions` streaming response is idle (queued, + prefill, or between tokens), to prevent reverse + proxies/tunnels with read timeouts from closing the + connection. Defaults to 0, which disables keep-alive + comments entirely. (default: 0) + --ssl-ca-certs SSL_CA_CERTS + The CA certificates file. (default: None) + --ssl-cert-reqs SSL_CERT_REQS + Whether client certificate is required (see stdlib ssl + module's). (default: 0) + --ssl-certfile SSL_CERTFILE + The file path to the SSL cert file. (default: None) + --ssl-ciphers SSL_CIPHERS + SSL cipher suites for HTTPS (TLS 1.2 and below only). + Example: 'ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA- + CHACHA20-POLY1305' (default: None) + --ssl-keyfile SSL_KEYFILE + The file path to the SSL key file. (default: None) + --tokens-only, --no-tokens-only + If set to True, only enable the Tokens In<>Out + endpoint. This is intended for use in a Disaggregated + Everything setup. (default: False) + --tool-call-parser {apertus,cohere_command3,cohere_command4,deepseek_v3,deepseek_v31,deepseek_v32,deepseek_v4,deepseek_v41,dots,ernie45,functiongemma,gemma4,gigachat3,glm45,glm47,granite,granite-20b-fc,granite4,hermes,hf,hunyuan_a13b,hy_v3,hy_v4,inkling,internlm,jamba,k2_horizon,kimi_k2,kimi_k3,lfm2,ling3,llama3_json,llama4_json,llama4_pythonic,longcat,mimo,minicpm5,minimax_m2,minimax_m3,mistral,muse_glimmer,olmo3,openai,phi4_mini_json,plamo3,poolside_v1,pythonic,qwen3_coder,qwen3_json,qwen3_xml,seed_oss,step3,step3p5,xlam} or name registered in --tool-parser-plugin + Select the tool call parser depending on the model + that you're using. This is used to parse the model- + generated tool call into OpenAI API format. Required + for `--enable-auto-tool-choice`. You can choose any + option from the built-in parsers or register a plugin + via `--tool-parser-plugin`. (default: None) + --tool-parser-plugin TOOL_PARSER_PLUGIN + Special the tool parser plugin write to parse the + model-generated tool into OpenAI API format, the name + register in this plugin can be used in `--tool-call- + parser`. (default: ) + --tool-server TOOL_SERVER + Comma-separated list of host:port pairs (IPv4, IPv6, + or hostname). Examples: 127.0.0.1:8000, [::1]:8000, + localhost:1234. Or `demo` for built-in demo tools + (browser and Python code interpreter). WARNING: The + `demo` Python tool executes model-generated code in + Docker without network isolation by default. See the + security guide for more information. (default: None) + --tool-strict-level {auto,function,parameter} + Server-side floor for structural-tag based tool + calling, applied on top of the per-tool `strict` + field. `auto` follows the request's tool choice and + per-tool strictness; `function` constrains the tool- + call envelope (markup and function name) for every + request with tools; `parameter` additionally pins + argument schemas, as if every tool were `strict: + true`. (default: auto) + --trust-request-chat-template, --no-trust-request-chat-template + Whether to trust the chat template provided in the + request. If False, the server will always use the chat + template specified by `--chat-template` or the ones + from tokenizer. (default: False) + --trust-request-mm-kwargs, --no-trust-request-mm-kwargs + Whether to trust per-request multimodal kwargs + (`mm_processor_kwargs` and `media_io_kwargs`). If + False, the server rejects non-empty values because + they can change multimodal preprocessing resource + usage. Only enable this when API clients are trusted. + (default: False) + --uds UDS Unix domain socket path. If set, host and port + arguments are ignored. (default: None) + --uvicorn-log-level {critical,debug,error,info,trace,warning} + Log level for uvicorn. (default: info) + +ModelConfig: + Configuration for the model. + + --allow-deprecated-quantization, --no-allow-deprecated-quantization + Whether to allow deprecated quantization methods. + (default: False) + --allowed-local-media-path ALLOWED_LOCAL_MEDIA_PATH + Allowing API requests to read local images or videos + from directories specified by the server file system. + This is a security risk. Should only be enabled in + trusted environments. (default: ) + --allowed-media-domains ALLOWED_MEDIA_DOMAINS [ALLOWED_MEDIA_DOMAINS ...] + If set, only media URLs that belong to this domain can + be used for multi-modal inputs. (default: None) + --code-revision CODE_REVISION + The specific revision to use for the model code on the + Hugging Face Hub. It can be a branch name, a tag name, + or a commit id. If unspecified, will use the default + version. (default: None) + --config-format ['auto', 'hf', 'mistral'] + The format of the model config to load: + - "auto" will try to load the config in hf format if + available after trying to load in mistral format. + - "hf" will load the config in hf format. + - "mistral" will load the config in mistral format. + (default: auto) + --convert {auto,classify,embed,none} + Convert the model using adapters defined in + [vllm.model_executor.models.adapters][]. The most + common use case is to adapt a text generation model to + be used for pooling tasks. (default: auto) + --disable-cascade-attn, --no-disable-cascade-attn + --disable-cascade-attn is deprecated and has no effect + since cascade attention has been removed. It will be + removed in v0.33.0. (default: None) + --disable-sliding-window, --no-disable-sliding-window + Whether to disable sliding window. If True, we will + disable the sliding window functionality of the model, + capping to sliding window size. If the model does not + support sliding window, this argument is ignored. + (default: False) + --dtype {auto,bfloat16,float,float16,float32,half} + Data type for model weights and activations: + - "auto" will use FP16 precision for FP32 and FP16 + models, and BF16 precision for BF16 models. + - "half" for FP16. Recommended for AWQ quantization. + - "float16" is the same as "half". + - "bfloat16" for a balance between precision and + range. + - "float" is shorthand for FP32 precision. + - "float32" for FP32 precision. (default: auto) + --enable-cumem-allocator, --no-enable-cumem-allocator + Enable the custom cumem allocator to leverage advanced + GPU memory allocation features such as multi-node + NVLink support. + Sleep mode automatically enables this allocator. Only + cuda and hip platforms are supported. (default: False) + --enable-nccl-comm-suspend, --no-enable-nccl-comm-suspend + Enable releasing NCCL communicator memory during sleep + mode (``ncclCommSuspend``/``ncclCommResume``). + Experimental; when disabled (the default) sleep still + releases weights/KV-cache memory as before. (default: + False) + --enable-prompt-embeds, --no-enable-prompt-embeds + If `True`, enables passing text embeddings as inputs + via the `prompt_embeds` key. + WARNING: The vLLM engine may crash if incorrect shape + of embeddings is passed. Only enable this flag for + trusted users! (default: False) + --enable-return-routed-experts, --no-enable-return-routed-experts + Capture and return routed-experts auxiliary outputs. + (default: False) + --enable-sleep-mode, --no-enable-sleep-mode + Enable sleep mode for the engine (only cuda and hip + platforms are supported). (default: False) + --enable-trace-replay, --no-enable-trace-replay + Whether to allow requests to set + `SamplingParams.trace_decode_token_ids`, which forces + decoding to follow a predetermined token sequence + while still computing real logprobs. Reserved for + debugging and RL workflows: enabling it reserves a + per-request trace buffer, so it is off by default. + (default: False) + --enforce-eager, --no-enforce-eager + Whether to always use eager-mode PyTorch. If True, we + will disable CUDA graph and always execute the model + in eager mode. If False, we will use CUDA graph and + eager execution in hybrid for maximal performance and + flexibility. + NOTE: This disables both `torch.compile` and CUDA + graphs, and is equivalent to setting `-cc.mode=none + -cc.cudagraph_mode=none`. (default: False) + --generation-config GENERATION_CONFIG + The folder path to the generation config. Defaults to + `"auto"`, the generation config will be loaded from + model path. If set to `"vllm"`, no generation config + is loaded, vLLM defaults will be used. If set to a + folder path, the generation config will be loaded from + the specified folder path. If `max_new_tokens` is + specified in generation config, then it sets a server- + wide limit on the number of output tokens for all + requests. (default: auto) + --hf-config-path HF_CONFIG_PATH + Name or path of the Hugging Face config to use. If + unspecified, model name or path will be used. + (default: None) + --hf-overrides HF_OVERRIDES + If a dictionary, contains arguments to be forwarded to + the Hugging Face config. If a callable, it is called + to update the HuggingFace config. (default: {}) + --hf-token [HF_TOKEN] + The token to use as HTTP bearer authorization for + remote files . If `True`, will use the token generated + when running `hf auth login` (stored in + `~/.cache/huggingface/token`). (default: None) + --io-processor-plugin IO_PROCESSOR_PLUGIN + IOProcessor plugin name to load at model startup + (default: None) + --logits-processors LOGITS_PROCESSORS [LOGITS_PROCESSORS ...] + One or more logits processors' fully-qualified class + names or class definitions (default: None) + --logprobs-mode {processed_logits,processed_logprobs,raw_logits,raw_logprobs} + Indicates the content returned in the logprobs and + prompt_logprobs. Supported mode: 1) raw_logprobs, 2) + processed_logprobs, 3) raw_logits, 4) + processed_logits. Raw means the values before applying + any logit processors, like bad words. Processed means + the values after applying all processors, including + temperature and top_k/top_p. Note: for + prompt_logprobs, processed_* and raw_* yield identical + results because prompt tokens do not go through + sampling processors. (default: raw_logprobs) + --max-logprobs MAX_LOGPROBS + Maximum number of log probabilities to return when + `logprobs` is specified in `SamplingParams`. The + default value comes the default for the OpenAI Chat + Completions API. -1 means no cap, i.e. all + (output_length * vocab_size) logprobs are allowed to + be returned and it may cause OOM. (default: 20) + --max-model-len MAX_MODEL_LEN + Model context length (prompt and output). If + unspecified, will be automatically derived from the + model config. + When passing via `--max-model-len`, supports + k/m/g/K/M/G in human-readable format. Examples: + - 1k -> 1000 + - 1K -> 1024 + - 25.6k -> 25,600 + - -1 or 'auto' -> Automatically choose the maximum + model length that fits in GPU memory. This will use + the model's maximum context length if it fits, + otherwise it will find the largest length that can be + accommodated. + Parse human-readable integers like '1k', '2M', etc. + Including decimal values with decimal multipliers. + Also accepts -1 or 'auto' as a special value for auto- + detection. + Examples: + - '1k' -> 1,000 + - '1K' -> 1,024 + - '25.6k' -> 25,600 + - '-1' or 'auto' -> -1 (special value for auto- + detection) + (default: None) + --model MODEL Name or path of the Hugging Face model to use. It is + also used as the content for `model_name` tag in + metrics output when `served_model_name` is not + specified. (default: Qwen/Qwen3-0.6B) + --model-class-overrides MODEL_CLASS_OVERRIDES + Override the model class used for one or more + architectures, mapping the architecture name to a + `"module:class"` target (the same format accepted by + `ModelRegistry.register_model`). This registers the + target class at runtime, e.g. + `{"GlmMoeDsaForCausalLM": "vllm.models.deepseek_v32.nv + idia.model:DeepseekV32ForCausalLM"}`. This argument is + for development and debugging purposes only. + Should either be a valid JSON string or JSON keys + passed individually. (default: {}) + --model-impl ['auto', 'terratorch', 'transformers', 'vllm'] + Which implementation of the model to use: + - "auto" will try to use the vLLM implementation, if + it exists, and fall back to the Transformers + implementation if no vLLM implementation is available. + - "vllm" will use the vLLM model implementation. + - "transformers" will use the Transformers model + implementation. + - "terratorch" will use the TerraTorch model + implementation. (default: auto) + --override-generation-config OVERRIDE_GENERATION_CONFIG + Overrides or sets generation config. e.g. + `{"temperature": 0.5}`. If used with `--generation- + config auto`, the override parameters will be merged + with the default config from the model. If used with + `--generation-config vllm`, only the override + parameters are used. + Should either be a valid JSON string or JSON keys + passed individually. (default: {}) + --pooler-config POOLER_CONFIG + Pooler config which controls the behaviour of output + pooling in pooling models. + API docs: https://docs.vllm.ai/en/v0.32.0/api/vllm/con + fig/#vllm.config.PoolerConfig + Should either be a valid JSON string or JSON keys + passed individually. (default: None) + --quantization QUANTIZATION, -q QUANTIZATION + Method used to quantize the weights. If `None`, we + first check the `quantization_config` attribute in the + model config file. If that is `None`, we assume the + model weights are not quantized and use `dtype` to + determine the data type of the weights. (default: + None) + --quantization-config QUANTIZATION_CONFIG + User-facing quantization configuration. Carries per- + layer-kind specs (linear, moe) and ignore patterns; + see :class:`QuantizationConfigArgs`. Auto-populated + from the matching online shorthand when `quantization` + is one of the values in + `ONLINE_QUANT_SHORTHAND_NAMES`. + Should either be a valid JSON string or JSON keys + passed individually. (default: None) + --renderer-num-workers RENDERER_NUM_WORKERS + Number of worker threads in the renderer thread pool. + The pool is consumed by the async renderer path (e.g. + the OpenAI-compatible API server started by `vllm + serve`) to parallelize tokenization, chat template + rendering, and multimodal preprocessing across + concurrent requests. + The offline `LLM` entrypoint uses the synchronous + renderer path and processes prompts (including + multimodal preprocessing) serially, so this setting + has no effect there. (default: 1) + --return-sampling-mask, --no-return-sampling-mask + Whether to return the post-processing token support + for each sample. (default: False) + --revision REVISION The specific model version to use. It can be a branch + name, a tag name, or a commit id. If unspecified, will + use the default version. (default: None) + --runner {auto,draft,generate,pooling} + The type of model runner to use. Each vLLM instance + only supports one model runner, even if the same model + can be used for multiple types. (default: auto) + --seed SEED Random seed for reproducibility. + We must set the global seed because otherwise, + different tensor parallel workers would sample + different tokens, leading to inconsistent results. + (default: 0) + --served-model-name SERVED_MODEL_NAME [SERVED_MODEL_NAME ...] + The model name(s) used in the API. If multiple names + are provided, the server will respond to any of the + provided names. The model name in the model field of a + response will be the first name in this list. If not + specified, the model name will be the same as the + `--model` argument. Noted that this name(s) will also + be used in `model_name` tag content of prometheus + metrics, if multiple names provided, metrics tag will + take the first one. (default: None) + --skip-tokenizer-init, --no-skip-tokenizer-init + Skip initialization of tokenizer and detokenizer. + Expects valid `prompt_token_ids` and `None` for prompt + from the input. The generated output will contain + token ids. (default: False) + --sleep-mode-offload-cudagraph, --no-sleep-mode-offload-cudagraph + Back up CUDA graph memory to CPU during sleep, + restored in place on wake. Takes effect with + enable_sleep_mode, the cumem backend and CUDA graphs. + (default: False) + --sleep-preserve-parameter-names SLEEP_PRESERVE_PARAMETER_NAMES [SLEEP_PRESERVE_PARAMETER_NAMES ...] + Parameter-name globs to preserve across level-2 sleep. + Use target globs for frozen Engram table weights and + scales omitted by the trainer (e.g. + ``*.engram.embed_tokens.weight*``). Use + ``draft.<name>`` for draft-private parameters, or + ``draft.*`` for all. The sender must omit these + parameters; loaders must preserve their storage. + (default: []) + --tokenizer TOKENIZER + Name or path of the Hugging Face tokenizer to use. If + unspecified, model name or path will be used. + (default: None) + --tokenizer-mode ['auto', 'cohere', 'deepseek_v32', 'deepseek_v4', 'hf', 'inkling', 'kimi_k3', 'mistral'] + Tokenizer mode: + - "auto" will use the tokenizer from `mistral_common` + for Mistral models if available, otherwise it will use + the "hf" tokenizer. + - "hf" will use the fast tokenizer if available. + - "mistral" will always use the tokenizer from + `mistral_common`. + - "deepseek_v32" will always use the tokenizer from + `deepseek_v32`. + - "deepseek_v4" will always use the tokenizer from + `deepseek_v4`. + - "deepseek_v41" will use the DeepSeek V4.1 prompt + encoder. + - "kimi_k3" will always use the "hf" tokenizer but + render chat prompts with Kimi K3's Python XTML + encoding instead of a Jinja template. + - "cohere" uses the standard HF tokenizer but renders + the chat template via the `cohere_melody` library + (cmd3 / cmd4 templates) instead of Jinja, and surfaces + grounded-citation metadata on responses. + - Other custom values can be supported via plugins. + To swap the Rust BPE backend that powers HF fast + tokenizers for the + [fastokens](https://github.com/crusoecloud/fastokens) + implementation, set `VLLM_USE_FASTOKENS=1` instead — + that override applies to any mode that loads an HF + fast tokenizer (`hf`, `deepseek_v32`, `deepseek_v4`, + …). (default: auto) + --tokenizer-revision TOKENIZER_REVISION + The specific revision to use for the tokenizer on the + Hugging Face Hub. It can be a branch name, a tag name, + or a commit id. If unspecified, will use the default + version. (default: None) + --trust-remote-code, --no-trust-remote-code + Trust remote code (e.g., from HuggingFace) when + downloading the model and tokenizer. (default: False) + --use-fp64-gumbel, --no-use-fp64-gumbel + Whether to use FP64 (instead of FP32) random noise for + Gumbel-max and equivalent exponential-race sampling. + FP64 preserves lower-tail sampling events that fp32 + uniform/exponential draws can truncate, at the cost of + significantly lower throughput on most GPUs. (default: + False) + +LoadConfig: + Configuration for loading the model weights. + + --download-dir DOWNLOAD_DIR + Directory to download and load the weights, default to + the default cache directory of Hugging Face. (default: + None) + --ignore-patterns IGNORE_PATTERNS [IGNORE_PATTERNS ...] + The list of patterns to ignore when loading the model. + Default to "original/**/*" to avoid repeated loading + of llama's checkpoints. (default: ['original/**/*']) + --load-format LOAD_FORMAT + The format of the model weights to load. + - "auto" will try to load the weights in the + safetensors format and fall back to the pytorch bin + format if safetensors format is not available. + - "pt" will load the weights in the pytorch bin + format. + - "safetensors" will load the weights in the + safetensors format. + - "instanttensor" will load the Safetensors weights on + CUDA devices using InstantTensor, which enables + distributed loading with pipelined prefetching and + fast direct I/O. + - "ipc_cache" will map post-quantized weights from a + local weight cache daemon via CUDA IPC for fast engine + restarts. Launch the daemon with `vllm preload` first. + - "npcache" will load the weights in pytorch format + and store a numpy cache to speed up the loading. + - "dummy" will initialize the weights with random + values, which is mainly for profiling. + - "tensorizer" will use CoreWeave's tensorizer library + for fast weight loading. See the Tensorize vLLM Model + script in the Examples section for more information. + - "runai_streamer" will load the Safetensors weights + using Run:ai Model Streamer. + - "runai_streamer_sharded" will load weights from pre- + sharded checkpoint files using Run:ai Model Streamer. + - "sharded_state" will load weights from pre-sharded + checkpoint files, supporting efficient loading of + tensor-parallel models. + - "mistral" will load weights from consolidated + safetensors files used by Mistral models. + - "modelexpress" will load weights using ModelExpress. + - Other custom values can be supported via plugins. + (default: auto) + --model-loader-extra-config MODEL_LOADER_EXTRA_CONFIG + Extra config for model loader. This will be passed to + the model loader corresponding to the chosen + load_format. (default: {}) + --pt-load-map-location PT_LOAD_MAP_LOCATION + The map location for loading pytorch checkpoint, to + support loading checkpoints can only be loaded on + certain devices like "cuda", this is equivalent to + `{"": "cuda"}`. Another supported format is mapping + from different devices like from GPU 1 to GPU 0: + `{"cuda:1": "cuda:0"}`. Note that when passed from + command line, the strings in dictionary need to be + double quoted for json parsing. For more details, see + the original doc for `map_location` parameter in + [`torch.load`][] parameter. (default: cpu) + --safetensors-load-strategy {eager,lazy,prefetch,torchao,None} + Specifies the loading strategy for safetensors + weights. + - None (default): Uses memory-mapped (lazy) loading. + When an NFS filesystem is detected and the total + checkpoint size fits within 90%% of available RAM, + prefetching is enabled automatically. + - "lazy": Weights are memory-mapped from the file. + This enables on-demand loading and is highly efficient + for models on local storage. Unlike the default + (None), auto-prefetch on NFS is not performed. + - "eager": The entire file is read into CPU memory + upfront before loading. This is recommended for models + on network filesystems (e.g., Lustre, NFS) as it + avoids inefficient random reads, significantly + speeding up model initialization. However, it uses + more CPU RAM. + - "prefetch": Checkpoint files are read into the OS + page cache before workers load them, speeding up the + model loading phase. Useful on network or high-latency + storage. + - "torchao": Weights are loaded in upfront and then + reconstructed into torchao tensor subclasses. This is + used when the checkpoint was quantized using torchao + and saved using safetensors. Needs `torchao >= + 0.14.0`. (default: None) + --safetensors-prefetch-block-size SAFETENSORS_PREFETCH_BLOCK_SIZE + Read size in bytes for each safetensors checkpoint + file prefetch. + Parse human-readable integers like '1k', '2M', etc. + Including decimal values with decimal multipliers. + Examples: + - '1k' -> 1,000 + - '1K' -> 1,024 + - '25.6k' -> 25,600 + (default: 16777216) + --safetensors-prefetch-num-threads SAFETENSORS_PREFETCH_NUM_THREADS + Number of worker threads used to prefetch safetensors + checkpoint files into the OS page cache when + safetensors prefetching is enabled. (default: 8) + --use-tqdm-on-load, --no-use-tqdm-on-load + Whether to enable tqdm for showing progress bar when + loading model weights. (default: True) + +AttentionConfig: + Configuration for attention mechanisms in vLLM. + + --attention-backend ATTENTION_BACKEND + Attention backend to use. Use "auto" or None for + automatic selection. (default: None) + +MambaConfig: + Configuration for Mamba SSM backends. + + --enable-mamba-cache-stochastic-rounding, --no-enable-mamba-cache-stochastic-rounding + Enable stochastic rounding when writing SSM state to + fp16 cache. Uses random bits to unbias the rounding + error, which can improve numerical stability for long + sequences. (default: False) + --mamba-backend MAMBA_BACKEND + Mamba SSU backend to use. (default: + MambaBackendEnum.TRITON) + --mamba-cache-philox-rounds MAMBA_CACHE_PHILOX_ROUNDS + Number of Philox PRNG rounds for stochastic rounding + random number generation. 0 uses the Triton default. + Higher values improve randomness quality at the cost + of compute. (default: 0) + --mamba-ssu-algorithm {auto,horizontal,simple,vertical,None} + Selective state update algorithm to use with the + FlashInfer backend. None defaults to FlashInfer's + "auto" algorithm. Forced algorithms must be supported + by FlashInfer for the active GPU, state dtype, and + decoding mode. (default: None) + +StructuredOutputsConfig: + Dataclass which contains structured outputs config for the engine. + + --reasoning-parser REASONING_PARSER + Select the reasoning parser depending on the model + that you're using. This is used to parse the reasoning + content into OpenAI API format. (default: ) + --reasoning-parser-plugin REASONING_PARSER_PLUGIN + Path to a dynamically reasoning parser plugin that can + be dynamically loaded and registered. (default: ) + +ParallelConfig: + Configuration for the distributed execution. + + --all2all-backend {allgather_reducescatter,deepep_high_throughput,deepep_low_latency,deepep_v2,flashinfer_all2allv,flashinfer_nvlink_one_sided,flashinfer_nvlink_two_sided,moonep,mori_high_throughput,mori_low_latency,naive,nixl_ep,pplx} + All2All backend for MoE expert parallel communication. + Available options: + - "allgather_reducescatter": All2all based on + allgather and reducescatter + - "deepep_high_throughput": Use deepep high-throughput + kernels + - "deepep_low_latency": Use deepep low-latency kernels + - "mori_high_throughput": MoRI EP with InterNodeV1 for + multi-node + - "mori_low_latency": MoRI EP with InterNodeV1LL for + multi-node + - "moonep": MoonEP balanced EP with dynamic redundant + experts (NVLink) + - "nixl_ep": Use nixl-ep kernels + - "flashinfer_nvlink_one_sided": Use flashinfer high- + throughput a2a kernels + - "flashinfer_nvlink_two_sided": Use flashinfer two- + sided kernels for mnnvl + - "passthrough": No all2all at all: the MoE backend + dispatches and combines itself (e.g. the FlashInfer + MoE-EP megakernels). Bound automatically for such MoE + backends; not selectable from the CLI. (default: + allgather_reducescatter) + --cp-kv-cache-interleave-size CP_KV_CACHE_INTERLEAVE_SIZE + Interleave size of kv_cache storage while using DCP. + Store interleave_size tokens on dcp_rank i, then store + next interleave_size tokens on dcp_rank i+1. + Interleave_size=1: token-level alignment, where token + `i` is stored on dcp_rank `i % dcp_world_size`. + Interleave_size=block_size: block-level alignment, + where tokens are first populated to the preceding + ranks. Tokens are then stored in (rank i+1, block j) + only after (rank i, block j) is fully occupied. + Block_size should be greater than or equal to + cp_kv_cache_interleave_size. Block_size should be + divisible by cp_kv_cache_interleave_size. + When --cp-kv-cache-interleave-size is omitted (None), + the interleave size is resolved automatically based on + NIXL transfer requirements. Explicit settings take + priority. (default: None) + --cpu-distributed-timeout-seconds CPU_DISTRIBUTED_TIMEOUT_SECONDS + Timeout (in seconds) for cpu communication groups. If + None, PyTorch's default timeout is used (1800s for + gloo). (default: None) + --data-parallel-address DATA_PARALLEL_ADDRESS, -dpa DATA_PARALLEL_ADDRESS + Address of data parallel cluster head-node. (default: + None) + --data-parallel-backend DATA_PARALLEL_BACKEND, -dpb DATA_PARALLEL_BACKEND + Backend for data parallel, either "mp" or "ray". + (default: mp) + --data-parallel-external-lb, --no-data-parallel-external-lb, -dpe + Whether to use "external" DP LB mode. Applies only to + online serving and when data_parallel_size > 0. This + is useful for a "one-pod-per-rank" wide-EP setup in + Kubernetes. Supported only for MoE deployments; non- + MoE models should use independent vLLM instances + without --data-parallel-* arguments. Set implicitly + when --data-parallel-rank is provided explicitly to + vllm serve. (default: False) + --data-parallel-hybrid-lb, --no-data-parallel-hybrid-lb, -dph + Whether to use "hybrid" DP LB mode. Applies only to + online serving and when data_parallel_size > 0. + Enables running an AsyncLLM and API server on a "per- + node" basis where vLLM load balances between local + data parallel ranks, but an external LB balances + between vLLM nodes/replicas. Set explicitly in + conjunction with + --data-parallel-start-rank. (default: False) + --data-parallel-multi-port-external-lb, -dpm + Run a node-local supervisor that launches one + external-LB API server per local data parallel rank + and exposes aggregated health on a supervisor port. + (default: False) + --data-parallel-rank DATA_PARALLEL_RANK, -dpn DATA_PARALLEL_RANK + Data parallel rank of this instance. When set, enables + external load balancer mode for MoE data-parallel + deployments. Unsupported for non-MoE models; launch + independent vLLM instances instead. (default: None) + --data-parallel-rpc-port DATA_PARALLEL_RPC_PORT, -dpp DATA_PARALLEL_RPC_PORT + Fixed port for data parallel RPC communication. All + nodes must use the same port. (default: None) + --data-parallel-size DATA_PARALLEL_SIZE, -dp DATA_PARALLEL_SIZE + Number of data parallel groups. MoE layers will be + sharded according to the product of the tensor, + prefill-context, and data parallel sizes. (default: 1) + --data-parallel-size-local DATA_PARALLEL_SIZE_LOCAL, -dpl DATA_PARALLEL_SIZE_LOCAL + Number of data parallel replicas to run on this node. + (default: None) + --data-parallel-start-rank DATA_PARALLEL_START_RANK, -dpr DATA_PARALLEL_START_RANK + Starting data parallel rank for secondary nodes. + (default: None) + --dbo-decode-token-threshold DBO_DECODE_TOKEN_THRESHOLD + The threshold for dual batch overlap for batches only + containing decodes. If the number of tokens in the + request is greater than this threshold, microbatching + will be used. Otherwise, the request will be processed + in a single batch. (default: 32) + --dbo-prefill-token-threshold DBO_PREFILL_TOKEN_THRESHOLD + The threshold for dual batch overlap for batches that + contain one or more prefills. If the number of tokens + in the request is greater than this threshold, + microbatching will be used. Otherwise, the request + will be processed in a single batch. (default: 512) + --dcp-comm-backend {a2a,ag_rs,None} + Communication backend for Decode Context Parallel + (DCP). + - "ag_rs": AllGather + ReduceScatter (existing + behavior) + - "a2a": All-to-All exchange of partial outputs + LSE, + then combine with Triton kernel. Reduces NCCL calls + from 3 to 2 per layer for MLA models. + `None` selects the model default, which is "ag_rs" + unless the model overrides it via [`set_dcp_defaults`] + [vllm.config.ParallelConfig.set_dcp_defaults]. + (default: None) + --dcp-kv-cache-interleave-size DCP_KV_CACHE_INTERLEAVE_SIZE + Interleave size of kv_cache storage while using DCP. + dcp_kv_cache_interleave_size has been replaced by + cp_kv_cache_interleave_size, and will be deprecated + when PCP is fully supported. (default: 1) + --dcp-q-replicate, --no-dcp-q-replicate + Replicate the MLA query projection within each DCP + group so decode can skip the query all-gather. + With DCP the KV cache is sharded across the group, so + the standard MLA decode path all-gathers the query + every step. Replicating the (small) query projection + at load time lets each rank materialize the full + group-local head set and skip that collective, at the + cost of computing the projection redundantly on every + rank in the group. (default: None) + --decode-context-parallel-size DECODE_CONTEXT_PARALLEL_SIZE, -dcp DECODE_CONTEXT_PARALLEL_SIZE + Number of ranks that shard the decode KV cache. DCP + does not expand the process world size. Without PCP, + DCP reuses TP ranks. With PCP, DCP either spans the + PCP axis or the full TP x PCP block. (default: 1) + --device-ids DEVICE_IDS + Comma-separated physical GPU device IDs or UUIDs to + use (e.g. --device-ids "2,3,5,7"). Avoids setting + CUDA_VISIBLE_DEVICES, preserving full GPU topology + visibility for GPU-NIC affinity and DeepGEMM. Note: + has no effect with Ray executors; use Ray placement + groups for GPU selection instead. (default: None) + --disable-custom-all-reduce, --no-disable-custom-all-reduce + Disable the custom all-reduce kernel and fall back to + NCCL. (default: False) + --disable-nccl-for-dp-synchronization, --no-disable-nccl-for-dp-synchronization + Forces the dp synchronization logic in + vllm/v1/worker/dp_utils.py to use Gloo instead of + NCCL for its all reduce. + Defaults to True when async scheduling is enabled, + False otherwise. (default: None) + --distributed-executor-backend ['external_launcher', 'mp', 'ray', 'uni'] + Backend to use for distributed model workers, either + "ray" or "mp" (multiprocessing). If the product of + pipeline_parallel_size and tensor_parallel_size is + less than or equal to the number of GPUs available, + "mp" will be used to keep processing on a single host. + Otherwise, an error will be raised. To use "mp" you + must also set nnodes, and to use "ray" you must + manually set distributed_executor_backend to "ray". + Note: + [TPU](https://docs.vllm.ai/projects/tpu/en/latest/) + platform only supports Ray for distributed inference. + (default: None) + --distributed-timeout-seconds DISTRIBUTED_TIMEOUT_SECONDS + Timeout in seconds for distributed operations (e.g., + init_process_group). If set, this value is passed to + torch.distributed.init_process_group as the timeout + parameter. If None, PyTorch's default timeout is used + (600s for NCCL). Increase this for multi-node setups + where model downloads may be slow. (default: None) + --dp-sync-interval DP_SYNC_INTERVAL + Steps between DP finish-sync all-reduces; must match + across DP ranks. (default: 16) + --elastic-ep-max-dp-size ELASTIC_EP_MAX_DP_SIZE + Maximum data parallel size supported by elastic expert + parallelism. (default: None) + --enable-batch-sharded-sampling, --no-enable-batch-sharded-sampling + Use sharded sampling across tensor parallel ranks. + Each rank samples a slice of the batch instead of + every rank sampling all of it. Currently defaults to + False if not set. Enabling it explicitly raises when + the config cannot support it (`tensor_parallel_size` + must be > 1, `max_num_seqs` at least + `tensor_parallel_size`, and `max_logprobs` non- + negative). Models opt in by implementing + `compute_logits_local`. (default: None) + --enable-dbo, --no-enable-dbo + Enable dual batch overlap for the model executor. + (default: False) + --enable-elastic-ep, --no-enable-elastic-ep + Enable elastic expert parallelism with stateless NCCL + groups for DP/EP. (default: False) + --enable-ep-weight-filter, --no-enable-ep-weight-filter + Skip non-local expert weights during model loading + when expert parallelism is active. Each rank only + reads its own expert shard from disk, which can + drastically reduce storage I/O for MoE models with + per-expert weight tensors (e.g. DeepSeek, Mixtral, + Kimi-K2.5). Has no effect on 3D fused-expert + checkpoints (e.g. GPT-OSS) or non-MoE models. + (default: False) + --enable-eplb, --no-enable-eplb + Enable expert parallelism load balancing for MoE + layers. (default: False) + --enable-expert-parallel, --no-enable-expert-parallel, -ep + Use expert parallelism instead of tensor parallelism + for MoE layers. (default: False) + --enable-fault-tolerance, --no-enable-fault-tolerance + Enable fault tolerance for detailed error recovery, + such as scaling down fault DPEngineCore. (default: + False) + --enable-hc-sp, --no-enable-hc-sp + Enable sequence parallelism for HyperConnection (HC) + computation. Currently supported by Qwen4Exp on CUDA + with TP>1 and PP=1 (ignored when TP=1). Does not + change MoE parallelism. MoE SP may enable HC SP + independently of this flag. (default: False) + --enable-shm-tensor-arena, --no-enable-shm-tensor-arena + Route large CPU tensors (e.g. multimodal + ``pixel_values``) in the engine→worker broadcast + through a zero-copy shared-memory arena instead of + sending a copy to every reader. **Experimental**: may + be reconciled with vLLM's existing multimodal shm + tensor caching in a future release. Opt-in; most + beneficial for multimodal serving with large images + and tensor parallelism. Active only when all queue + readers are node-local; reserves slots in + ``/dev/shm``. (default: False) + --eplb-config EPLB_CONFIG + Expert parallelism configuration. + API docs: https://docs.vllm.ai/en/v0.32.0/api/vllm/con + fig/#vllm.config.EPLBConfig + Should either be a valid JSON string or JSON keys + passed individually. (default: + EPLBConfig(window_size=1000, step_interval=3000, + num_redundant_experts=0, log_balancedness=False, + log_balancedness_interval=1, use_async=True, + policy='default', communicator=None, + enable_migration_batching=False)) + --expert-placement-strategy {linear,round_robin} + The expert placement strategy for MoE layers: + - "linear": Experts are placed in a contiguous manner. + For example, with 4 experts and 2 ranks, rank 0 will + have experts [0, 1] and rank 1 will have experts [2, + 3]. + - "round_robin": Experts are placed in a round-robin + manner. For example, with 4 experts and 2 ranks, rank + 0 will have experts [0, 2] and rank 1 will have + experts [1, 3]. This strategy can help improve load + balancing for grouped expert models with no redundant + experts. (default: linear) + --fault-tolerance-config FAULT_TOLERANCE_CONFIG + The configurations for fault tolerance. + API docs: https://docs.vllm.ai/en/v0.32.0/api/vllm/con + fig/#vllm.config.FaultToleranceConfig + Should either be a valid JSON string or JSON keys + passed individually. (default: + FaultToleranceConfig(engine_recovery_timeout_sec=120)) + --master-addr MASTER_ADDR + distributed master address for multi-node distributed + inference when distributed_executor_backend is mp. + (default: 127.0.0.1) + --master-port MASTER_PORT + distributed master port for multi-node distributed + inference when distributed_executor_backend is mp. + (default: 29501) + --max-parallel-loading-workers MAX_PARALLEL_LOADING_WORKERS + Maximum number of parallel loading workers when + loading model sequentially in multiple batches. To + avoid RAM OOM when using tensor parallel and large + models. (default: None) + --nnodes NNODES, -n NNODES + num of nodes for multi-node distributed inference when + distributed_executor_backend is mp. (default: 1) + --node-rank NODE_RANK, -r NODE_RANK + distributed node rank for multi-node distributed + inference when distributed_executor_backend is mp. + (default: 0) + --numa-bind, --no-numa-bind + Enable NUMA binding for GPU worker subprocesses. + By default, workers are pinned to their GPU's NUMA- + local CPUs and memory; on PCT-capable Xeons they also + auto-bind to the SKU's PCT priority cores. (default: + False) + --numa-bind-cpus NUMA_BIND_CPUS [NUMA_BIND_CPUS ...] + Optional CPU lists to bind each GPU worker to. + Specify one CPU list per visible GPU, for example + `["0-3", "4-7", "8-11", "12-15"]`. When set, vLLM uses + `numactl --physcpubind` instead of `--cpunodebind`. + This is useful for custom policies such as binding to + PCT or other high-frequency cores. Each entry must use + `numactl --physcpubind` CPU-list syntax, for example + `"0-3"` or `"0,2,4-7"`. (default: None) + --numa-bind-nodes NUMA_BIND_NODES [NUMA_BIND_NODES ...] + NUMA node to bind each GPU worker to. + Specify one NUMA node per visible GPU, for example + `[0, 0, 1, 1]` for a 4-GPU system with GPUs 0-1 on + NUMA node 0 and GPUs 2-3 on NUMA node 1. If unset and + `numa_bind=True`, vLLM auto-detects the GPU-to-NUMA + topology. The values are passed to `numactl --membind` + and `--cpunodebind`, so they must be valid `numactl` + NUMA node indices. (default: None) + --pipeline-parallel-size PIPELINE_PARALLEL_SIZE, -pp PIPELINE_PARALLEL_SIZE + Number of pipeline parallel groups. (default: 1) + --prefill-context-parallel-size PREFILL_CONTEXT_PARALLEL_SIZE, -pcp PREFILL_CONTEXT_PARALLEL_SIZE + Number of ranks that split prefill sequence + computation. PCP expands the process world size but + does not increase the KV-cache shard count. (default: + 1) + --ray-workers-use-nsight, --no-ray-workers-use-nsight + Whether to profile Ray workers with nsight, see + https://docs.ray.io/en/latest/ray-observability/user- + guides/profiling.html#profiling-nsight-profiler. + (default: False) + --tensor-parallel-size TENSOR_PARALLEL_SIZE, -tp TENSOR_PARALLEL_SIZE + Number of tensor parallel groups. (default: 1) + --ubatch-size UBATCH_SIZE + Number of ubatch size. (default: 0) + --worker-cls WORKER_CLS + The full name of the worker class to use. If "auto", + the worker class will be determined based on the + platform. (default: auto) + --worker-extension-cls WORKER_EXTENSION_CLS + The full name of the worker extension class to use. + The worker extension class is dynamically inherited by + the worker class. This is used to inject new + attributes and methods to the worker class for use in + collective_rpc calls. (default: ) + +CacheConfig: + Configuration for the KV cache. + + --block-size BLOCK_SIZE + Size of a contiguous cache block in number of tokens. + Accepts None (meaning "use default"). After + construction, always int. (default: None) + --enable-mamba-shared-prefix-checkpoint, --no-enable-mamba-shared-prefix-checkpoint + Also register a Mamba "align" checkpoint at the + shared-prefix junction -- where an EAGLE/MTP sibling + was observed to resume -- instead of only at the + prompt tail. Off by default; only takes effect with + `mamba_cache_mode` "align", EAGLE on the Mamba group, + and a prefix match unit smaller than the Mamba block + size. (default: False) + --enable-prefix-caching, --no-enable-prefix-caching + Whether to enable prefix caching. (default: None) + --gpu-memory-utilization GPU_MEMORY_UTILIZATION, --device-memory-utilization GPU_MEMORY_UTILIZATION + The fraction of GPU memory to be used for the model + executor, which can range from 0 to 1. For example, a + value of 0.5 would imply 50% GPU memory utilization. + If unspecified, will use the default value of 0.92. + This is a per-instance limit, and only applies to the + current vLLM instance. It does not matter if you have + another vLLM instance running on the same GPU. For + example, if you have two vLLM instances running on the + same GPU, you can set the GPU memory utilization to + 0.5 for each instance. On non-GPU installs, this value + controls the corresponding device memory utilization. + (default: 0.92) + --kv-cache-dtype {auto,bfloat16,float16,fp8,fp8_ds_mla,fp8_e4m3,fp8_e5m2,fp8_inc,fp8_per_token_head,int4_per_token_head,int8_per_token_head,nvfp4,nvfp4_4over6,nvfp4_ds_mla,turboquant_3bit_nc,turboquant_4bit_nc,turboquant_k3v4_nc,turboquant_k8v4,ultraquant_4bit} + Data type for kv cache storage. If "auto", will use + model data type. CUDA 11.8+ supports fp8 (=fp8_e4m3) + and fp8_e5m2. ROCm (AMD GPU) supports fp8 (=fp8_e4m3). + Intel Gaudi (HPU) supports fp8 (using fp8_inc). Some + models (namely DeepSeekV3.2) default to fp8, set to + bfloat16 to use bfloat16 instead, this is an invalid + option for models that do not default to fp8. + "nvfp4_4over6" uses the NVFP4 layout and selects + between max/6 and max/4 scales per 16 values by + minimizing squared reconstruction error. (default: + auto) + --kv-cache-dtype-skip-layers KV_CACHE_DTYPE_SKIP_LAYERS [KV_CACHE_DTYPE_SKIP_LAYERS ...] + Layer patterns to skip KV cache quantization. Accepts + layer indices (e.g., '0', '2', '4') or attention type + names (e.g., 'sliding_window'). (default: []) + --kv-cache-memory-bytes KV_CACHE_MEMORY_BYTES + Size of KV Cache per GPU in bytes. By default, this is + set to None and vllm can automatically infer the kv + cache size based on gpu_memory_utilization. However, + users may want to manually specify the kv cache memory + size. kv_cache_memory_bytes allows more fine-grain + control of how much memory gets used when compared + with using gpu_memory_utilization. Note that + kv_cache_memory_bytes (when not-None) ignores + gpu_memory_utilization + Parse human-readable integers like '1k', '2M', etc. + Including decimal values with decimal multipliers. + Examples: + - '1k' -> 1,000 + - '1K' -> 1,024 + - '25.6k' -> 25,600 + (default: None) + --kv-offloading-backend {lmcache,native} + The backend to use for KV cache offloading. Supported + backends include 'native' (vLLM native CPU + offloading), 'lmcache'. KV offloading is only + activated when kv_offloading_size is set. (default: + native) + --kv-offloading-size KV_OFFLOADING_SIZE + Size of the KV cache offloading buffer in GiB. When TP + > 1, this is the total buffer size summed across all + TP ranks. By default, this is set to None, which means + no KV offloading is enabled. When set, vLLM will + enable KV cache offloading to CPU using the + kv_offloading_backend. (default: None) + --kv-sharing-fast-prefill, --no-kv-sharing-fast-prefill + In some KV sharing setups, e.g. YOCO + (https://arxiv.org/abs/2405.05254), some layers can + skip tokens corresponding to prefill. This flag + enables attention metadata for eligible layers to be + overridden with metadata necessary for implementing + this optimization in some models (e.g. Gemma3n) + (default: False) + --mamba-block-size MAMBA_BLOCK_SIZE + Size of a contiguous cache block in number of tokens + for mamba cache. Can be set only when prefix caching + is enabled. Value must be a multiple of 8 to align + with causal_conv1d kernel. (default: None) + --mamba-cache-dtype {auto,bfloat16,float16,float32} + The data type to use for the Mamba cache (both the + conv as well as the ssm state). If set to 'auto', the + data type will be inferred from the model config. + (default: auto) + --mamba-cache-mode {align,none} + The cache strategy for Mamba layers: + - "none": set when prefix caching is disabled. + - "align": only cache the mamba state of the last + token of each scheduler step and when the token is at + position i * block_size. This is the default when + prefix caching is enabled. (default: none) + --mamba-ssm-cache-dtype {auto,bfloat16,float16,float32} + The data type to use for the Mamba cache (ssm state + only, conv state will still be controlled by + mamba_cache_dtype). If set to 'auto', the data type + for the ssm state will be determined by + mamba_cache_dtype. (default: auto) + --num-gpu-blocks-override NUM_GPU_BLOCKS_OVERRIDE + Number of GPU blocks to use. This overrides the + profiled `num_gpu_blocks` if specified. Does nothing + if `None`. Used for testing preemption. (default: + None) + --prefix-cache-retention-interval PREFIX_CACHE_RETENTION_INTERVAL + Token interval between retained sliding-window and + Mamba prefix-cache checkpoints. ``0`` retains only + semantic checkpoints, including the latest replay + boundary and shared-prefix junctions. Positive values + additionally retain periodic checkpoints at the + specified interval, which must be a multiple of the + scheduler block size. ``None`` retains checkpoints + densely. Applies only to sliding-window and Mamba + cache groups. (default: 0) + --prefix-caching-hash-algo {sha256,sha256_cbor,xxhash,xxhash_cbor} + Set the hash algorithm for prefix caching: + - "sha256" uses Pickle for object serialization before + hashing. This is the current default, as SHA256 is the + most secure choice to avoid potential hash collisions. + - "sha256_cbor" provides a reproducible, cross- + language compatible hash. It serializes objects using + canonical CBOR and hashes them with SHA-256. + - "xxhash" uses Pickle serialization with xxHash + (128-bit) for faster, non-cryptographic hashing. + Requires the optional ``xxhash`` package. IMPORTANT: + Use of a hashing algorithm that is not considered + cryptographically secure theoretically increases the + risk of hash collisions, which can cause undefined + behavior or even leak private information in multi- + tenant environments. Even if collisions are still very + unlikely, it is important to consider your security + risk tolerance against the performance benefits before + turning this on. + - "xxhash_cbor" combines canonical CBOR serialization + with xxHash for reproducible hashing. Requires the + optional ``xxhash`` package. (default: sha256) + --prefix-match-unit PREFIX_MATCH_UNIT + The finest token boundary (in tokens) a prefix-cache + hit can land on. + Prefix-cache keys are computed every + `prefix_match_unit` tokens. It can be set finer than + the physical KV cache block sizes (e.g. 32 vs a + 1024-token hybrid-model block) as long as every KV + cache group's `block_size` is divisible by it, + enabling cache hits at boundaries inside a physical + block. It controls matching granularity only, not how + often states are stored. + This equals to the `hash_block_size` used throughout + the KV cache code. (default: None) + --replayssm-buffer-len REPLAYSSM_BUFFER_LEN + ReplaySSM logical history length B for Mamba2. Triton + uses B physical rows and FlashInfer uses B+T, where T + is the target verification length. Kimi-K3 speculative + decode does not use B. Default 16. (default: 16) + --swa-bounded-replay, --no-swa-bounded-replay + Keep the sliding-window KV of models that support it + (DeepSeek-V4.1) out of prefix caching and rebuild it + after a prefix hit by recomputing the hit's last + window. The layers past the last KV-source layer then + also run eager prefill steps on each request's + trailing window only. Requires model runner V2. + (default: True) + --use-replayssm, --no-use-replayssm + Use the ReplaySSM Mamba2 decode kernel: cache recent + SSM inputs and skip the per-step full-state store, + writing the checkpoint back only on flush. Requires + mamba_cache_mode 'none' or 'align' (prefix caching) + and the Triton or FlashInfer mamba backend. Mamba2 + speculative decode requires FlashInfer and + mamba_cache_mode 'none'. In align mode flushes are + most efficient when mamba_block_size is a multiple of + replayssm_buffer_len, but this is not required. + (default: False) + +OffloadConfig: + Configuration for model weight offloading to reduce GPU memory usage. + + --cpu-offload-gb CPU_OFFLOAD_GB + The space in GiB to offload to CPU, per GPU. Default + is 0, which means no offloading. Intuitively, this + argument can be seen as a virtual way to increase the + GPU memory size. For example, if you have one 24 GB + GPU and set this to 10, virtually you can think of it + as a 34 GB GPU. Then you can load a 13B model with + BF16 weight, which requires at least 26GB GPU memory. + Note that this requires fast CPU-GPU interconnect, as + part of the model is loaded from CPU memory to GPU + memory on the fly in each model forward pass. This + uses UVA (Unified Virtual Addressing) for zero-copy + access. (default: 0) + --cpu-offload-params CPU_OFFLOAD_PARAMS [CPU_OFFLOAD_PARAMS ...] + The set of parameter name segments to target for CPU + offloading. Unmatched parameters are not offloaded. If + this set is empty, parameters are offloaded non- + selectively until the memory limit defined by + `cpu_offload_gb` is reached. Examples: + - For parameter name "mlp.experts.w2_weight": + - "experts" or "experts.w2_weight" will match. + - "expert" or "w2" will NOT match (must be + exact segments). This allows distinguishing parameters + like "w2_weight" and "w2_weight_scale". (default: + set()) + --offload-backend {auto,prefetch,uva} + The backend for weight offloading. Options: + - "auto": Selects based on which sub-config has non- + default values (prefetch if offload_group_size > 0, + uva if cpu_offload_gb > 0). + - "uva": UVA (Unified Virtual Addressing) zero-copy + offloading. + - "prefetch": Async prefetch with group-based layer + offloading. (default: auto) + --offload-group-size OFFLOAD_GROUP_SIZE + Group every N layers together. Offload last + `offload_num_in_group` layers of each group. Default + is 0 (disabled). Example: group_size=8, num_in_group=2 + offloads layers 6,7,14,15,22,23,... Unlike + cpu_offload_gb, this uses explicit async prefetching + to hide transfer latency. (default: 0) + --offload-num-in-group OFFLOAD_NUM_IN_GROUP + Number of layers to offload per group. Must be <= + offload_group_size. Default is 1. (default: 1) + --offload-params OFFLOAD_PARAMS [OFFLOAD_PARAMS ...] + The set of parameter name segments to target for + prefetch offloading. Unmatched parameters are not + offloaded. If this set is empty, ALL parameters of + each offloaded layer are offloaded. Uses segment + matching: "w13_weight" matches + "mlp.experts.w13_weight" but not + "mlp.experts.w13_weight_scale". (default: set()) + --offload-prefetch-step OFFLOAD_PREFETCH_STEP + Number of layers to prefetch ahead. Higher values hide + more latency but use more GPU memory. Default is 1. + (default: 1) + +MultiModalConfig: + Controls the behavior of multimodal models. + + --enable-mm-embeds, --no-enable-mm-embeds + If `True`, enables passing multimodal embeddings: for + `LLM` class, this refers to tensor inputs under + `multi_modal_data`; for the OpenAI-compatible server, + this refers to chat messages with content `"type": + "*_embeds"`. + When enabled with `--limit-mm-per-prompt` set to 0 for + a modality, precomputed embeddings skip count + validation for that modality, saving memory by not + loading encoder modules while still enabling + embeddings as an input. Limits greater than 0 still + apply to embeddings. + WARNING: The vLLM engine may crash if incorrect shape + of embeddings is passed. Only enable this flag for + trusted users! (default: False) + --interleave-mm-strings, --no-interleave-mm-strings + Enable fully interleaved support for multimodal + prompts, while using + --chat-template-content-format=string. (default: + False) + --language-model-only, --no-language-model-only + If True, disables all multimodal inputs by setting all + modality limits to 0. Equivalent to setting `--limit- + mm-per-prompt` to 0 for every modality. (default: + False) + --limit-mm-per-prompt LIMIT_MM_PER_PROMPT + The maximum number of input items and options allowed + per prompt for each modality. + Defaults to 999 for each modality. + Legacy format (count only): {"image": 16, "video": 2} + Configurable format (with options): {"video": + {"count": 1, "num_frames": 32, "width": 512, "height": + 512}, "image": {"count": 5, "width": 512, "height": + 512}} + Mixed format (combining both): {"image": 16, "video": + {"count": 1, "num_frames": 32, "width": 512, "height": + 512}} + Should either be a valid JSON string or JSON keys + passed individually. (default: {}) + --media-io-kwargs MEDIA_IO_KWARGS + Additional args passed to process media inputs, keyed + by modalities. For example, to set num_frames for + video, set `--media-io-kwargs '{"video": + {"num_frames": 40} }'` + Should either be a valid JSON string or JSON keys + passed individually. (default: {}) + --mm-device-do-normalize, --no-mm-device-do-normalize + Move the do_normalize computation in the mm + preprocessing to before the ViT, and let the device + do it, so that CPU computation can be saved. (default: + None) + --mm-encoder-attn-backend MM_ENCODER_ATTN_BACKEND + Optional override for the multi-modal encoder + attention backend when using vision transformers. + Accepts any value from `vllm.v1.attention.backends.reg + istry.AttentionBackendEnum` (e.g. `FLASH_ATTN`). + (default: None) + --mm-encoder-attn-dtype {fp8,None} + Optional dtype override for ViT encoder attention. Set + to `"fp8"` to enable FP8 quantization via the + FlashInfer cuDNN backend. When set to `"fp8"` without + a scale file, dynamic scaling is used automatically. + See docs/features/quantization/fp8_vit_attn.md for + details. (default: None) + --mm-encoder-fp8-scale-path MM_ENCODER_FP8_SCALE_PATH + Path to a JSON file containing per-layer FP8 Q/K/V + scales for ViT encoder attention. When provided (with + `mm_encoder_attn_dtype="fp8"`), static scaling is + used. When omitted, dynamic scaling is used. (default: + None) + --mm-encoder-fp8-scale-save-margin MM_ENCODER_FP8_SCALE_SAVE_MARGIN + Safety margin multiplied onto scales when auto-saving. + A value > 1 leaves headroom so that inputs with larger + activations than the calibration set do not overflow + FP8 range. Default 1.5. (default: 1.5) + --mm-encoder-fp8-scale-save-path MM_ENCODER_FP8_SCALE_SAVE_PATH + When set with dynamic FP8 scaling + (`mm_encoder_attn_dtype="fp8"` and no + `mm_encoder_fp8_scale_path`), saves the calibrated + scales to this file after the amax history buffer is + full. The saved file can then be used as + `mm_encoder_fp8_scale_path` in subsequent runs. + (default: None) + --mm-encoder-only, --no-mm-encoder-only + When enabled, skips the language component of the + model. + This is usually only valid in disaggregated Encoder + process. (default: False) + --mm-encoder-tp-mode {data,weights} + Indicates how to optimize multi-modal encoder + inference using tensor parallelism (TP). + - `"weights"`: Within the same vLLM engine, split the + weights of each layer across TP ranks. (default TP + behavior) + - `"data"`: Within the same vLLM engine, split the + batched input data across TP ranks to process the data + in parallel, while hosting the full weights on each TP + rank. This batch-level DP is not to be confused with + API request-level DP (which is controlled by `--data- + parallel-size`). This is only supported on a per-model + basis and falls back to `"weights"` if the encoder + does not support DP. (default: weights) + --mm-hasher-algorithm {blake3,sha256,sha512} + Hash algorithm to use for multi-modal input caching. + Use `"sha256"` or `"sha512"` for FIPS-compliant + deployments. (default: blake3) + --mm-ipc-gpu-memory-gb MM_IPC_GPU_MEMORY_GB + Amount of GPU memory (in GiB) sequestered on the + engine's device for GPU-side multimodal work in the + API-server (frontend) process, such as hardware video + decoding. + This budget is carved out of the engine's KV-cache + memory so the headroom physically exists, and frontend + GPU decode paths acquire from a blocking byte-counting + semaphore of this size before allocating on the + device. + Set to `0` (default) to disable frontend GPU + multimodal memory gating. (default: 0) + --mm-processor-cache-gb MM_PROCESSOR_CACHE_GB + The size (in GiB) of the multi-modal processor cache, + which is used to avoid re-processing past multi-modal + inputs. + This cache is duplicated for each API process and + engine core process, resulting in a total memory usage + of `mm_processor_cache_gb * (api_server_count + + data_parallel_size)`. + A single processed item larger than this budget is + served uncached (with a warning) instead of failing. + Raise this value to cache such items. + Set to `0` to disable this cache completely (not + recommended). (default: 4) + --mm-processor-cache-type {lru,shm} + Type of cache to use for the multi-modal + preprocessor/mapper. If `shm`, use shared memory FIFO + cache. If `lru`, use mirrored LRU cache. (default: + lru) + --mm-processor-device {auto,cpu} + Device the HF multi-modal processor runs the + image/video transform on. Convenience for `--mm- + processor-kwargs '{"device": ...}'`: the value is + resolved here and stored there, it is not kept as + separate state. Only takes effect for HF "fast" + (torchvision-backed) processors, which accept a + `device` argument; the others ignore it and stay on + CPU. + "auto" uses the accelerator on encoder instances of an + encode/prefill/decode deployment -- an EC producer + that is not also a consumer allocates no KV cache, so + its accelerator is not contended by the language model + -- and then only when `--mm-tensor-ipc=torch_shm` can + carry device tensors, since every other transport + would copy the result back to the host and that copy + costs more than it saves. "auto" resolves to "cpu" + everywhere else. (default: auto) + --mm-processor-kwargs MM_PROCESSOR_KWARGS + Arguments to be forwarded to the model's processor for + multi-modal data, e.g., image processor. Overrides for + the multi-modal processor obtained from + `transformers.AutoProcessor.from_pretrained`. + The available overrides depend on the model that is + being run. + For example, for Phi-3-Vision: `{"num_crops": 4}`. + Should either be a valid JSON string or JSON keys + passed individually. (default: None) + --mm-shm-cache-max-object-size-mb MM_SHM_CACHE_MAX_OBJECT_SIZE_MB + Size limit (in MiB) for each object stored in the + multi-modal processor shared memory cache. Only + effective when `mm_processor_cache_type` is `"shm"`. + (default: 128) + --mm-tensor-ipc {direct_rpc,torch_shm} + IPC (inter-process communication) method for + multimodal tensors. + - "direct_rpc": Use msgspec serialization via RPC + - "torch_shm": Use torch.multiprocessing shared memory + for zero-copy IPC Defaults to "direct_rpc". (default: + direct_rpc) + --skip-mm-profiling, --no-skip-mm-profiling + When enabled, skips multimodal memory profiling and + only profiles with language backbone model during + engine initialization. + This reduces engine startup time but shifts the + responsibility to users for estimating the peak memory + usage of the activation of multimodal encoder and + embedding cache. (default: False) + --video-pruning-method {evs,vidcom2} + Video token pruning algorithm applied when + `video_pruning_rate` > 0: + - "evs": Efficient Video Sampling. + - "vidcom2": Video Compression Commander. (default: + evs) + --video-pruning-rate VIDEO_PRUNING_RATE + Fraction of video tokens to prune from each video. + Value sits in range [0;1); pruning is enabled when it + is greater than 0. The pruning algorithm is selected + by `video_pruning_method`. (default: None) + +LoRAConfig: + Configuration for LoRA. + + --default-mm-loras DEFAULT_MM_LORAS + Dictionary mapping specific modalities to LoRA model + paths; this field is only applicable to multimodal + models and should be leveraged when a model always + expects a LoRA to be active when a given modality is + present. Note that currently, if a request provides + multiple additional modalities, each of which have + their own LoRA, we do NOT apply default_mm_loras + because we currently only support one lora adapter per + prompt. When run in offline mode, the lora IDs for n + modalities will be automatically assigned to 1-n with + the names of the modalities in alphabetic order. + Should either be a valid JSON string or JSON keys + passed individually. (default: None) + --enable-lora, --no-enable-lora + If True, enable handling of LoRA adapters. (default: + None) + --enable-mixed-moe-lora-format, --no-enable-mixed-moe-lora-format + If True, force the engine to use the universal 2D MoE + LoRA wrapper (`FusedMoEWithLoRA`) regardless of the + model's `is_3d_moe_weight` flag, so that 2D-format and + 3D-format MoE LoRA adapters can be served in the same + deployment. Only meaningful for MoE models; ignored + otherwise. Default False keeps the existing model- + driven behavior. (default: False) + --enable-moe-shared-loras, --no-enable-moe-shared-loras + If True, load MoE expert adapters in the "shared- + outer" layout, where the gate/up (`w1`/`w3`) lora_A + and the down (`w2`) lora_B are shared across all + experts (stored once with expert-dim 1) instead of + per-expert. The shared factors are broadcast to the + expert count at kernel time. 3D-weight MoE models use + `FusedMoEWithLoRA` for this layout. Only meaningful + for MoE models whose adapters use this layout; ignored + otherwise. (default: False) + --enable-tower-connector-lora, --no-enable-tower-connector-lora + If `True`, LoRA support for the tower (vision encoder) + and connector of multimodal models will be enabled. + This is an experimental feature and currently only + supports some MM models such as the Qwen VL series. + The default is False. (default: False) + --fully-sharded-loras, --no-fully-sharded-loras + By default, only half of the LoRA computation is + sharded with tensor parallelism. Enabling this will + use the fully sharded layers. At high sequence length, + max rank or tensor parallel size, this is likely + faster. (default: False) + --lora-dtype {auto,bfloat16,float16} + Data type for LoRA. If auto, will default to base + model dtype. (default: auto) + --lora-target-modules LORA_TARGET_MODULES [LORA_TARGET_MODULES ...] + Restrict LoRA to specific module suffixes (e.g., + ["o_proj", "qkv_proj"]). If None, all supported LoRA + modules are used. This allows deployment-time control + over which modules have LoRA applied, useful for + performance tuning. (default: None) + --max-cpu-loras MAX_CPU_LORAS + Maximum number of LoRAs to store in CPU memory. Must + be >= than `max_loras`. (default: None) + --max-lora-cls-labels MAX_LORA_CLS_LABELS + Maximum output size for LoRA classification heads. + Defaults to the base classification head size. + (default: None) + --max-lora-rank {1,8,16,32,64,128,256,320,512} + Max LoRA rank. (default: 16) + --max-loras MAX_LORAS + Max number of LoRAs in a single batch. (default: 1) + --specialize-active-lora, --no-specialize-active-lora + Whether to construct lora kernel grid by the number of + active LoRA adapters. When set to True, separate cuda + graphs will be captured for different counts of active + LoRAs (powers of 2 up to max_loras), which can improve + performance for variable LoRA usage patterns at the + cost of increased startup time and memory usage. Only + takes effect when cudagraph_specialize_lora is True. + (default: False) + +LoggingConfig: + vLLM logging. Default ``dictConfig`` adds a formatted stream handler. + + Supply a JSON object with ``--logging-config`` or individual fields with + dotted arguments such as ``--logging-config.log_level DEBUG`` and + ``--logging-config.formatter json``. ``--log-level`` and legacy + ``--log-config-file`` override their matching fields. Set + ``configure_logging`` to false to skip applying a ``dictConfig``. + + + --log-config-file PYLOGGING_CONFIG_FILE + --log-config-file is deprecated and will be removed in + v0.33.0. Use --logging-config.pylogging_config_file + instead. + --log-level {CRITICAL,ERROR,WARNING,INFO,DEBUG,NOTSET} + Shortcut for --logging-config.log_level. Overrides + that field if both are specified. + --logging-config LOGGING_CONFIG + Logging configuration. + API docs: https://docs.vllm.ai/en/v0.32.0/api/vllm/con + fig/#vllm.config.LoggingConfig + Should either be a valid JSON string or JSON keys + passed individually. + +ObservabilityConfig: + Configuration for observability - metrics and tracing. + + --collect-detailed-traces {all,model,worker,None} [{all,model,worker,None} ...] + It makes sense to set this only if `--otlp-traces- + endpoint` is set. If set, it will collect detailed + traces for the specified modules. This involves use of + possibly costly and or blocking operations and hence + might have a performance impact. + Note that collecting detailed timing information for + each request can be expensive. (default: None) + --cudagraph-metrics, --no-cudagraph-metrics + Enable CUDA graph metrics (number of padded/unpadded + tokens, runtime cudagraph dispatch modes, and their + observed frequencies at every logging interval). + (default: False) + --custom-histogram-buckets CUSTOM_HISTOGRAM_BUCKETS + Custom Prometheus histogram bucket boundaries, as a + JSON mapping from bucket-family key to a list of + strictly increasing, positive, finite upper bounds. + When a family key is present, its list replaces the + default buckets for every histogram in that family; + families not listed keep their defaults. Known + families: `request_latency`, `time_to_first_token`, + `inter_token_latency`, `iteration_tokens`, + `request_params_n`, `request_num_preemptions`, + `request_tokens`, `kv_cache_residency`. Example: + `--custom-histogram-buckets '{"request_latency": + [0.01, 0.05, 0.1, 0.5]}'`. Note that every extra + bucket adds one time series per metric and label set. + Should either be a valid JSON string or JSON keys + passed individually. (default: None) + --enable-layerwise-nvtx-tracing, --no-enable-layerwise-nvtx-tracing + Enable layerwise NVTX tracing. This traces the + execution of each layer or module in the model and + attach information such as input/output shapes to nvtx + range markers. Noted that this doesn't work with CUDA + graphs enabled. (default: False) + --enable-logging-iteration-details, --no-enable-logging-iteration-details + Enable detailed logging of iteration details. If set, + vllm EngineCore will log iteration details This + includes number of context/generation requests and + tokens and the elapsed cpu time for the iteration. + (default: False) + --enable-mfu-metrics, --no-enable-mfu-metrics + Enable Model FLOPs Utilization (MFU) metrics. + (default: False) + --jit-monitor-mode {error,warn} + How to handle post-warmup JIT compilation events. + (default: warn) + --jit-monitor-verbose, --no-jit-monitor-verbose + Log every monitored JIT compile with runtime details. + This can emit many logs and add overhead, so it is + intended for debugging. (default: False) + --kv-cache-metrics, --no-kv-cache-metrics + Enable KV cache residency metrics (lifetime, idle + time, reuse gaps). Uses sampling to minimize overhead. + Requires log stats to be enabled (i.e., --disable-log- + stats not set). (default: False) + --kv-cache-metrics-sample KV_CACHE_METRICS_SAMPLE + Sampling rate for KV cache metrics (0.0, 1.0]. Default + 0.01 = 1% of blocks. (default: 0.01) + --otlp-traces-endpoint OTLP_TRACES_ENDPOINT + Target URL to which OpenTelemetry traces will be sent. + (default: None) + --per-request-spec-decode-metrics {detailed,none,summary} + Include per-request speculative-decoding acceptance + metrics in the response under + `metrics.speculative_decoding`. `none` disables; + `summary` adds mean acceptance length, draft + acceptance rate, and the step-by-draft-length + histogram; `detailed` additionally records the ordered + per-step accepted/proposed arrays (one entry per + verify step). Only reported for single-sequence + requests (`n == 1`), mirroring the timing metrics. No + effect unless speculative decoding is enabled. + Independent of `--disable-log-stats`. This is the per- + request response-body counterpart of the aggregate + `vllm:spec_decode_*` Prometheus metrics. The response + field is experimental and its shape may change in a + future release. (default: none) + --show-hidden-metrics-for-version SHOW_HIDDEN_METRICS_FOR_VERSION + Enable deprecated Prometheus metrics that have been + hidden since the specified version. For example, if a + previously deprecated metric has been hidden since the + v0.7.0 release, you use `--show-hidden-metrics-for- + version=0.7` as a temporary escape hatch while you + migrate to new metrics. The metric is likely to be + removed completely in an upcoming release. (default: + None) + +SchedulerConfig: + Scheduler configuration. + + --async-scheduling, --no-async-scheduling + If set to False, disable async scheduling. Async + scheduling helps to avoid gaps in GPU utilization, + leading to better latency and throughput. (default: + None) + --disable-chunked-mm-input, --no-disable-chunked-mm-input + If set to true and chunked prefill is enabled, we do + not want to partially schedule a multimodal item. Only + used in V1 This ensures that if a request has a mixed + prompt (like text tokens TTTT followed by image tokens + IIIIIIIIII) where only some image tokens can be + scheduled (like TTTTIIIII, leaving IIIII), it will be + scheduled as TTTT in one step and IIIIIIIIII in the + next. (default: False) + --disable-hybrid-kv-cache-manager, --no-disable-hybrid-kv-cache-manager + If set to True, KV cache manager will allocate the + same size of KV cache for all attention layers even if + there are multiple type of attention layers like full + attention and sliding window attention. If set to + None, the default value will be determined based on + the environment and starting configuration. (default: + None) + --enable-chunked-prefill, --no-enable-chunked-prefill + If True, prefill requests can be chunked based on the + remaining `max_num_batched_tokens`. + The default value here is mainly for convenience when + testing. In real usage, this should be set in + `EngineArgs.create_engine_config`. (default: None) + --long-prefill-token-threshold LONG_PREFILL_TOKEN_THRESHOLD + For chunked prefill, a request is considered long if + the prompt is longer than this number of tokens. 0 + disables the cap (default). + The cap is not applied when the request is the only + one in the batch, since there is no other request for + it to starve. (default: 0) + --long-prefill-token-threshold-adaptive, --no-long-prefill-token-threshold-adaptive + Floor the effective long prefill token threshold at a + fair share of the token budget: max_num_batched_tokens + divided by the number of queued and running requests. + Only applies when long_prefill_token_threshold is + nonzero. (default: False) + --max-num-active-seqs MAX_NUM_ACTIVE_SEQS + Maximum number of requests the scheduler admits into + RUNNING. + ``max_num_seqs`` sizes the model runner (per-request + buffers and CUDA graph capture) and is also the + default admission limit. Setting this lowers only the + number of requests that may occupy RUNNING, so decode + batches stay smaller without shrinking runner or graph + capacity. Must be ``<= max_num_seqs``. ``None`` + (default) keeps current behavior. (default: None) + --max-num-batched-tokens MAX_NUM_BATCHED_TOKENS + Maximum number of tokens that can be processed in a + single iteration. + The default value here is mainly for convenience when + testing. In real usage, this should be set in + `EngineArgs.create_engine_config`. + Parse human-readable integers like '1k', '2M', etc. + Including decimal values with decimal multipliers. + Examples: + - '1k' -> 1,000 + - '1K' -> 1,024 + - '25.6k' -> 25,600 + (default: None) + --max-num-queued-reqs MAX_NUM_QUEUED_REQS + Maximum number of requests that can be in-flight + (waiting or running) at the same time, or None for no + limit. When the limit is reached, new requests are + rejected with HTTP 503 so the client can retry on + another instance. This bounds vLLM's otherwise + unbounded request queue and is primarily a coarse + capacity valve. + Unlike ``max_num_seqs``, which applies per data- + parallel rank, this limit is enforced in the API + server process and counts in-flight requests across + all DP ranks it routes to. Size it as roughly + ``data_parallel_size * max_num_seqs`` plus the desired + queue depth if it should not bind before per-rank + admission does. (default: None) + --max-num-queued-tokens MAX_NUM_QUEUED_TOKENS + Maximum total prompt tokens of requests currently in + the prefill phase, or None for no limit. When the + limit is reached, new requests are rejected with HTTP + 503. + This is a TTFT QoS mechanism: by setting it to + ``target_TTFT * prefill_throughput`` you reject + requests when the prefill backlog would exceed the + latency target. In a disaggregated prefill-decode + setup this maps directly to the prefill pool's + capacity. + Like ``max_num_queued_reqs``, this limit is enforced + in the API server process and covers the prefill + backlog across all DP ranks it routes to, so + ``prefill_throughput`` in the formula above is the + aggregate throughput of the deployment. + Note: the count is conservative. A partially + prefilled request still contributes its full + ``prompt_len`` until it transitions out of the prefill + phase, because the scheduler's per-iteration + ``num_computed_tokens`` progress is not propagated to + the API server process during prefill + (``EngineCoreOutput`` is only emitted once the request + starts producing tokens). Similarly, prefix-cache + hits (``num_cached_tokens``) are only known to the + OutputProcessor after prefill completes. This + overestimates the real backlog, causing earlier + rejection than strictly necessary — the safe direction + for QoS. The impact is limited to long prompts under + chunked prefill; short prompts that prefill in a + single iteration are unaffected. + Parse human-readable integers like '1k', '2M', etc. + Including decimal values with decimal multipliers. + Examples: + - '1k' -> 1,000 + - '1K' -> 1,024 + - '25.6k' -> 25,600 + (default: None) + --max-num-scheduled-tokens MAX_NUM_SCHEDULED_TOKENS + Maximum number of tokens that the scheduler may issue + in a single iteration. + This is usually equal to max_num_batched_tokens, but + can be smaller in cases when the model might append + tokens into the batch (such as speculative decoding). + Defaults to max_num_batched_tokens. + Parse human-readable integers like '1k', '2M', etc. + Including decimal values with decimal multipliers. + Examples: + - '1k' -> 1,000 + - '1K' -> 1,024 + - '25.6k' -> 25,600 + (default: None) + --max-num-seqs MAX_NUM_SEQS + Maximum number of sequences to be processed in a + single iteration. + The default value here is mainly for convenience when + testing. In real usage, this should be set in + `EngineArgs.create_engine_config`. (default: None) + --prefill-schedule-interval PREFILL_SCHEDULE_INTERVAL + For data-parallel deployments, only admit new prefill + requests once every N engine steps, aligned across DP + ranks, to better balance per-step forward-pass times. + (default: 1) + --scheduler-cls SCHEDULER_CLS + The scheduler class to use. + "vllm.v1.core.sched.scheduler.Scheduler" is the + default scheduler. Can be a class directly or the path + to a class of form "mod.custom_class". (default: None) + --scheduler-reserve-full-isl, --no-scheduler-reserve-full-isl + If True, the scheduler checks whether the full input + sequence length fits in the KV cache before admitting + a new request, rather than only checking the first + chunk. Prevents over-admission and KV cache thrashing + with chunked prefill. (default: True) + --scheduling-policy {fcfs,priority} + The scheduling policy to use: + - "fcfs" means first come first served, i.e. requests + are handled in order of arrival. + - "priority" means requests are handled based on given + priority (lower value means earlier handling) and time + of arrival deciding any ties). (default: fcfs) + --stream-interval STREAM_INTERVAL + The interval (or buffer size) for streaming in terms + of token length. A smaller value (1) makes streaming + smoother by sending each token immediately, while a + larger value (e.g., 10) reduces host overhead and may + increase throughput by batching multiple tokens before + sending. (default: 1) + --watermark WATERMARK + Fraction of total KV cache blocks to keep free (the + watermark) when admitting waiting or preempted + requests into the running queue. This headroom helps + avoid frequent KV cache eviction and the resulting + repeated preemption of requests when GPU memory is + scarce. Must be in the range [0.0, 1.0); 0.0 (the + default) disables the watermark. (default: 0.0) + +CompilationConfig: + Configuration for compilation. + + You must pass CompilationConfig to VLLMConfig constructor. + VLLMConfig's post_init does further initialization. If used outside of the + VLLMConfig, some fields will be left in an improper state. + + It contains PassConfig, which controls the custom fusion/transformation passes. + The rest has three parts: + + - Top-level Compilation control: + - [`mode`][vllm.config.CompilationConfig.mode] + - [`debug_dump_path`][vllm.config.CompilationConfig.debug_dump_path] + - [`cache_dir`][vllm.config.CompilationConfig.cache_dir] + - [`backend`][vllm.config.CompilationConfig.backend] + - [`custom_ops`][vllm.config.CompilationConfig.custom_ops] + - [`splitting_ops`][vllm.config.CompilationConfig.splitting_ops] + - [`compile_mm_encoder`][vllm.config.CompilationConfig.compile_mm_encoder] + - CudaGraph capture: + - [`cudagraph_mode`][vllm.config.CompilationConfig.cudagraph_mode] + - [`cudagraph_capture_sizes`] + [vllm.config.CompilationConfig.cudagraph_capture_sizes] + - [`max_cudagraph_capture_size`] + [vllm.config.CompilationConfig.max_cudagraph_capture_size] + - [`cudagraph_num_of_warmups`] + [vllm.config.CompilationConfig.cudagraph_num_of_warmups] + - [`cudagraph_copy_inputs`] + [vllm.config.CompilationConfig.cudagraph_copy_inputs] + - Inductor compilation: + - [`compile_sizes`][vllm.config.CompilationConfig.compile_sizes] + - [`compile_ranges_endpoints`] + [vllm.config.CompilationConfig.compile_ranges_endpoints] + - [`inductor_compile_config`] + [vllm.config.CompilationConfig.inductor_compile_config] + - [`inductor_passes`][vllm.config.CompilationConfig.inductor_passes] + - custom inductor passes + + Why we have different sizes for cudagraph and inductor: + - cudagraph: a cudagraph captured for a specific size can only be used + for the same size. We need to capture all the sizes we want to use. + - inductor: a graph compiled by inductor for a general shape can be used + for different sizes. Inductor can also compile for specific sizes, + where it can have more information to optimize the graph with fully + static shapes. However, we find the general shape compilation is + sufficient for most cases. It might be beneficial to compile for + certain small batchsizes, where inductor is good at optimizing. + + + --cudagraph-capture-sizes CUDAGRAPH_CAPTURE_SIZES [CUDAGRAPH_CAPTURE_SIZES ...] + Sizes to capture cudagraph. + - None (default): capture sizes are inferred from vllm + config. + - list[int]: capture sizes are specified as given. + (default: None) + --max-cudagraph-capture-size MAX_CUDAGRAPH_CAPTURE_SIZE + The maximum cudagraph capture size. + If cudagraph_capture_sizes is specified, this will be + set to the largest size in that list (or checked for + consistency if specified). If cudagraph_capture_sizes + is not specified, the list of sizes is generated + automatically following the pattern: + [1, 2, 4] + list(range(8, 256, 8)) + list( + range(256, max_cudagraph_capture_size + 1, 16)) + If not specified, max_cudagraph_capture_size is capped + at 512 by default, or 1024 on data center Blackwell + GPUs. This avoids OOM in tight memory scenarios with + small max_num_seqs, and limits capture of large graphs + that increase startup time and memory usage. Uniform + decode sizes are appended only within this default + ceiling. (default: None) + +KernelConfig: + Configuration for kernel selection and warmup behavior. + + --enable-flashinfer-autotune, --no-enable-flashinfer-autotune + If True, run FlashInfer autotuning during kernel + warmup. (default: None) + --ir-op-priority IR_OP_PRIORITY + vLLM IR op priority for dispatching/lowering during + the forward pass. Platform defaults appended + automatically during VllmConfig.__post_init__. + Should either be a valid JSON string or JSON keys + passed individually. (default: + IrOpPriorityConfig(rms_norm=[], fused_add_rms_norm=[], + gelu_and_mul_sparse=[])) + --linear-backend {aiter,auto,b12x,conch,cutlass,deep_gemm,emulation,exllama,fbgemm,flashinfer_b12x,flashinfer_cudnn,flashinfer_cutedsl,flashinfer_cutlass,flashinfer_trtllm,humming,machete,marlin,torch,triton,xpu,xpu_woq} + Backend for linear layer GEMM kernels. Available + options: + Layer types without an implementation from the + requested backend use automatic selection. + - "auto": Automatically select the best backend based + on model and hardware + - "cutlass": Use CUTLASS-based kernels + - "flashinfer_cutlass": Use FlashInfer with CUTLASS + kernels + - "flashinfer_cutedsl": Use FlashInfer with CuTe-DSL + kernels (BF16, NVFP4, MXFP8, W4A16_NVFP4) + - "flashinfer_trtllm": Use FlashInfer with TensorRT- + LLM kernels + - "flashinfer_cudnn": Use FlashInfer with cuDNN + kernels + - "flashinfer_b12x": Use FlashInfer b12x CuteDSL NVFP4 + GEMM (SM120+) + - "b12x": Use native B12X FP8 and FP4 linear kernels + on SM12x + - "marlin": Use Marlin kernels + - "triton": Use Triton-based kernels + - "deep_gemm": Use DeepGEMM kernels + - "torch": Use PyTorch native scaled_mm kernels + - "aiter": Use AMD AITer kernels (ROCm only) + - "machete": Use Machete kernels (mixed-precision) + - "fbgemm": Use FBGEMM kernels + - "conch": Use Conch mixed-precision kernels + - "exllama": Use Exllama mixed-precision kernels + - "emulation": Use slow dequant-to-BF16 emulation (for + testing only) + - "xpu": Use XPU kernels + - "xpu_woq": Use XPU kernels for weight-only + quantization (e.g. W8A16) (default: auto) + --moe-backend {aiter,aiter_mega_moe,aiter_triton_mxfp4_bf16,auto,b12x,batched_triton,cutlass,deep_gemm,deep_gemm_mega_moe,emulation,flashinfer_b12x,flashinfer_cutedsl,flashinfer_cutlass,flashinfer_moe_ep_cutedsl,flashinfer_moe_ep_mega_deep_gemm,flashinfer_trtllm,flydsl,hpc,humming,marlin,rdna3,triton,triton_unfused} + Backend for MoE expert computation kernels. Available + options: + - "auto": Automatically select the best backend based + on model and hardware + - "triton": Use Triton-based fused MoE kernels + - "batched_triton": Use batched Triton experts + (moe_mmk) on the batched activation format ([E_local, + max_num_tokens, K]) + - "deep_gemm": Use DeepGEMM kernels (FP8 block- + quantized only) + - "deep_gemm_mega_moe": Use DeepGEMM mega MoE kernels + - "cutlass": Use vLLM CUTLASS kernels + - "flashinfer_trtllm": Use FlashInfer with TRTLLM-GEN + kernels + - "flashinfer_cutlass": Use FlashInfer with CUTLASS + kernels + - "flashinfer_cutedsl": Use FlashInfer with CuteDSL + kernels (FP4 only) + - "flashinfer_moe_ep_cutedsl": Use FlashInfer's + CuTeDSL MoE-EP mega-kernel with NVFP4 weights (MXFP4 + checkpoints are requantized at load); requires + Blackwell, expert parallelism, and NVSHMEM + - "flashinfer_b12x": Use FlashInfer CuteDSL fused MoE + for SM12x (RTX Pro 6000 / DGX Spark) + - "b12x": Use b12x FP4 MoE kernels on SM12x + - "flashinfer_moe_ep_mega_deep_gemm": Use the + FlashInfer moe_ep expert-parallel mega-MoE with the + DeepGEMM megakernel, which consumes an MXFP4 + checkpoint verbatim (Blackwell, requires expert + parallel; DeepSeek-V4 only) + - "marlin": Use Marlin kernels (weight-only + quantization) + - "humming": Use Humming Mixed Precision kernels + - "triton_unfused": Use Triton unfused MoE kernels + - "aiter": Use AMD AITer kernels (ROCm only) + - "aiter_mega_moe": Use the AITER MegaMoE fused + dispatch/GEMM/combine kernel (ROCm gfx950, requires + DP+EP with a MoRI all2all backend; DeepSeek-V4 only) + - "aiter_triton_mxfp4_bf16": Use the AITER Triton + MXFP4 W4A16 (moe_gemm_a16w4) MoE kernel (ROCm + gfx942/gfx950/gfx1250) + - "flydsl": Use AMD FlyDSL kernels (ROCm only) + - "rdna3": Use the fused RDNA3 W4A16 HIP kernel (ROCm + gfx1100 only) + - "hpc": Use HPC kernels (FP8 and Hopper only) + - "emulation": use BF16/FP16 GEMM, dequantizing + weights and running QDQ on activations. (default: + auto) + --sparse-indexer-topk-backend {aiter,auto,cooperative,deep_select,flashinfer,per_row,persistent,torch} + Backend for the DSA sparse indexer decode top-k + kernel. Available options: + - "auto": The pre-existing chain (cooperative -> + persistent -> per_row); the other backends are opt-in + - "deep_select": Use DeepSelect kernels (SM100a/SM103a + only) + - "cooperative": Use vLLM's cooperative_topk kernel + - "persistent": Use vLLM's persistent_topk kernel + - "per_row": Use vLLM's top_k_per_row_decode kernel + - "flashinfer": Use FlashInfer's + top_k_ragged_transform kernel + - "torch": Use a plain torch.topk implementation + (debug reference) + - "aiter": Use AITER's top_k_per_row_decode kernel + (ROCm gfx950 only, requires VLLM_ROCM_USE_AITER) + Explicit values raise RuntimeError when their + constraints are not met. (default: auto) + +VllmConfig: + Dataclass which contains all vllm-related configuration. This + simplifies passing around the distinct configurations in the codebase. + + + --additional-config ADDITIONAL_CONFIG + Additional config for specified platform. Different + platforms may support different configs. Make sure the + configs are valid for the platform you are using. + Contents must be hashable. (default: {}) + --attention-config ATTENTION_CONFIG, -ac ATTENTION_CONFIG + Attention configuration. + API docs: https://docs.vllm.ai/en/v0.32.0/api/vllm/con + fig/#vllm.config.AttentionConfig + Should either be a valid JSON string or JSON keys + passed individually. (default: + AttentionConfig(backend=None, + minimax_m3_msa_decode_backend='triton', + backend_per_kind={}, flash_attn_version=None, + flash_attn_max_num_splits_for_cuda_graph=32, + tokenspeed_mla_min_split_kv=1, + tq_max_kv_splits_for_cuda_graph=32, + use_trtllm_attention=None, + disable_flashinfer_q_quantization=False, + mla_prefill_backend=None, + use_prefill_query_quantization=False, + indexer_kv_dtype='auto', indexer_sparse_logits=False, + hisparse_config=None, use_non_causal=False, + sparse_mla_force_mqa=False, + sparse_mla_prefill_workspace_factor=None, + flex_attn_block_m=None, flex_attn_block_n=None, + flex_attn_q_block_size=None, + flex_attn_kv_block_size=None)) + --aux-output-config AUX_OUTPUT_CONFIG + Execution auxiliary output configuration. + API docs: https://docs.vllm.ai/en/v0.32.0/api/vllm/con + fig/#vllm.config.AuxOutputConfig + Should either be a valid JSON string or JSON keys + passed individually. (default: + AuxOutputConfig(enable_return_routed_experts=False, + max_bytes=None)) + --compilation-config COMPILATION_CONFIG, -cc COMPILATION_CONFIG + `torch.compile` and cudagraph capture configuration + for the model. + As a shorthand, one can append compilation arguments + via + -cc.parameter=argument such as `-cc.mode=3` (same as + `-cc='{"mode":3}'`). + You can specify the full compilation config like so: + `{"mode": 3, "cudagraph_capture_sizes": [1, 2, 4, 8]}` + API docs: https://docs.vllm.ai/en/v0.32.0/api/vllm/con + fig/#vllm.config.CompilationConfig + Should either be a valid JSON string or JSON keys + passed individually. (default: {'mode': None, + 'debug_dump_path': None, 'cache_dir': '', + 'compile_cache_save_format': 'binary', 'backend': + 'inductor', 'custom_ops': [], 'ir_enable_torch_wrap': + None, 'splitting_ops': None, 'compile_mm_encoder': + False, 'cudagraph_mm_encoder': False, + 'encoder_cudagraph_token_budgets': [], + 'encoder_cudagraph_max_vision_items_per_batch': 0, + 'encoder_cudagraph_max_frames_per_batch': None, + 'cudagraph_decoder_replay': True, + 'decoder_replay_cudagraph_capture_sizes': [], + 'decoder_replay_trim_threshold': 768, 'compile_sizes': + None, 'compile_ranges_endpoints': None, + 'inductor_compile_config': + {'enable_auto_functionalized_v2': False}, + 'inductor_passes': {}, 'cudagraph_mode': None, + 'cudagraph_num_of_warmups': 0, + 'cudagraph_capture_sizes': None, + 'cudagraph_copy_inputs': False, + 'cudagraph_specialize_lora': True, + 'use_inductor_graph_partition': None, 'pass_config': + {}, 'max_cudagraph_capture_size': None, + 'dynamic_shapes_config': {'type': + <DynamicShapesType.BACKED: 'backed'>, + 'evaluate_guards': False, 'assume_32_bit_indexing': + False}, 'local_cache_dir': None, + 'fast_moe_cold_start': None, 'static_all_moe_layers': + []}) + --diffusion-config DIFFUSION_CONFIG, -dc DIFFUSION_CONFIG + Diffusion LLM (dLLM) configuration. + API docs: https://docs.vllm.ai/en/v0.32.0/api/vllm/con + fig/#vllm.config.DiffusionConfig + Should either be a valid JSON string or JSON keys + passed individually. (default: None) + --ec-manager-config EC_MANAGER_CONFIG + The configurations for custom encoder cache manager. + API docs: https://docs.vllm.ai/en/v0.32.0/api/vllm/con + fig/#vllm.config.EncoderCacheManagerConfig + Should either be a valid JSON string or JSON keys + passed individually. (default: EncoderCacheManagerConf + ig(encoder_cache_manager_cls=None, manager_config={})) + --ec-transfer-config EC_TRANSFER_CONFIG + The configurations for distributed EC cache transfer. + API docs: https://docs.vllm.ai/en/v0.32.0/api/vllm/con + fig/#vllm.config.ECTransferConfig + Should either be a valid JSON string or JSON keys + passed individually. (default: None) + --engram-config ENGRAM_CONFIG + N-gram embedding storage and sharding settings. + API docs: https://docs.vllm.ai/en/v0.32.0/api/vllm/con + fig/#vllm.config.EngramConfig + Should either be a valid JSON string or JSON keys + passed individually. (default: None) + --kernel-config KERNEL_CONFIG + Kernel configuration. + API docs: https://docs.vllm.ai/en/v0.32.0/api/vllm/con + fig/#vllm.config.KernelConfig + Should either be a valid JSON string or JSON keys + passed individually. (default: KernelConfig(ir_op_prio + rity=IrOpPriorityConfig(rms_norm=[], + fused_add_rms_norm=[], gelu_and_mul_sparse=[]), + enable_flashinfer_autotune=None, + enable_rocm_segmented_attn_autotune=False, + enable_cutedsl_warmup=True, enable_jit_warmup=True, + moe_backend='auto', + sparse_indexer_topk_backend='auto', + linear_backend='auto', linear_backend_per_quant=None)) + --kv-events-config KV_EVENTS_CONFIG + The configurations for event publishing. + API docs: https://docs.vllm.ai/en/v0.32.0/api/vllm/con + fig/#vllm.config.KVEventsConfig + Should either be a valid JSON string or JSON keys + passed individually. (default: None) + --kv-transfer-config KV_TRANSFER_CONFIG + The configurations for distributed KV cache transfer. + API docs: https://docs.vllm.ai/en/v0.32.0/api/vllm/con + fig/#vllm.config.KVTransferConfig + Should either be a valid JSON string or JSON keys + passed individually. (default: None) + --optimization-level OPTIMIZATION_LEVEL + The optimization level. These levels trade startup + time cost for performance, with -O0 having the best + startup time and -O3 having the best performance. -O2 + is used by default. See OptimizationLevel for full + description. (default: 2) + --performance-mode {balanced,interactivity,throughput} + Performance mode for runtime behavior, 'balanced' is + the default. 'interactivity' favors low end-to-end + per-request latency at small batch sizes (fine-grained + CUDA graphs, latency-oriented kernels). 'throughput' + favors aggregate tokens/sec at high concurrency + (larger CUDA graphs, more aggressive batching, + throughput-oriented kernels). (default: balanced) + --profiler-config PROFILER_CONFIG + Profiling configuration. + API docs: https://docs.vllm.ai/en/v0.32.0/api/vllm/con + fig/#vllm.config.ProfilerConfig + Should either be a valid JSON string or JSON keys + passed individually. (default: + ProfilerConfig(profiler=None, torch_profiler_dir='', + torch_profiler_activities=None, + proton_profiler_dir='', proton_context='shadow', + proton_data='tree', proton_backend=None, + proton_mode=None, proton_hook=None, + proton_output_format=None, + proton_graph_attribution=False, + torch_profiler_with_stack=True, + torch_profiler_with_flops=False, + torch_profiler_use_gzip=True, + torch_profiler_dump_cuda_time_total=True, + torch_profiler_record_shapes=False, + torch_profiler_with_memory=False, + capture_torch_profiler=False, + detailed_trace_annotation=False, + ignore_frontend=False, delay_iterations=0, + max_iterations=0, warmup_iterations=0, + active_iterations=5, wait_iterations=0)) + --reasoning-config REASONING_CONFIG + The configurations for reasoning model. + API docs: https://docs.vllm.ai/en/v0.32.0/api/vllm/con + fig/#vllm.config.ReasoningConfig + Should either be a valid JSON string or JSON keys + passed individually. (default: None) + --spec-method {bailing_hybrid_mtp,bailing_hybrid_v3_mtp,custom_class,deepseek_mtp,dflash,dots3_note_mtp,draft_model,dspark,eagle,eagle3,ernie_mtp,exaone4_5_mtp,exaone_moe_mtp,extract_hidden_states,gemma4_mtp,glm4_moe_lite_mtp,glm4_moe_mtp,glm5_next_mtp,glm_ocr_mtp,hy_v3_mtp,hy_v4_mtp,inkling_mtp,kimi_k3_mtp,longcat_flash_mtp,medusa,mimo_mtp,mimo_v2_mtp,minimax_m3_mtp,mlp_speculator,mtp,nemotron_h_mtp,ngram,ngram_gpu,pangu_ultra_moe_mtp,qwen3_5_mtp,qwen3_next_mtp,qwen4_exp_mtp,step3p5_mtp,suffix,None} + The name of the speculative method to use. If users + provide and set the `model` param, the speculative + method type will be detected automatically if + possible, if `model` param is not provided, the method + name must be provided. + If using `ngram` method, the related configuration + `prompt_lookup_max` and `prompt_lookup_min` should be + considered. (default: None) + --spec-model SPEC_MODEL + The name of the draft model, eagle head, or additional + weights, if provided. (default: None) + --spec-tokens SPEC_TOKENS + The number of speculative tokens, if provided. It will + default to the number in the draft model config if + present, otherwise, it is required. (default: None) + --speculative-config SPECULATIVE_CONFIG, -sc SPECULATIVE_CONFIG + Speculative decoding configuration. + API docs: https://docs.vllm.ai/en/v0.32.0/api/vllm/con + fig/#vllm.config.SpeculativeConfig + Should either be a valid JSON string or JSON keys + passed individually. (default: None) + --structured-outputs-config STRUCTURED_OUTPUTS_CONFIG + Structured outputs configuration. + API docs: https://docs.vllm.ai/en/v0.32.0/api/vllm/con + fig/#vllm.config.StructuredOutputsConfig + Should either be a valid JSON string or JSON keys + passed individually. (default: + StructuredOutputsConfig(backend='auto', + disable_any_whitespace=False, + disable_additional_properties=False, + reasoning_parser='', reasoning_parser_plugin='', + enable_in_reasoning=False)) + --watermark-config WATERMARK_CONFIG + Text watermarking configuration. + API docs: https://docs.vllm.ai/en/v0.32.0/api/vllm/con + fig/#vllm.config.WatermarkConfig + Should either be a valid JSON string or JSON keys + passed individually. (default: None) + --weight-transfer-config WEIGHT_TRANSFER_CONFIG + The configurations for weight transfer during RL + training. + API docs: https://docs.vllm.ai/en/v0.32.0/api/vllm/con + fig/#vllm.config.WeightTransferConfig + Should either be a valid JSON string or JSON keys + passed individually. (default: None) + +When passing JSON CLI arguments, the following sets of arguments are equivalent: + --json-arg '{"key1": "value1", "key2": {"key3": "value2"}}' + --json-arg.key1 value1 --json-arg.key2.key3 value2 + +Additionally, list elements can be passed individually using +: + --json-arg '{"key4": ["value3", "value4", "value5"]}' + --json-arg.key4+ value3 --json-arg.key4+='value4,value5' diff --git a/tests/fixtures/canary/sglang-0.5.22/sglang/__init__.py b/tests/fixtures/canary/sglang-0.5.22/sglang/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/fixtures/canary/sglang-0.5.22/sglang/_version.py b/tests/fixtures/canary/sglang-0.5.22/sglang/_version.py new file mode 100644 index 00000000..28d4a244 --- /dev/null +++ b/tests/fixtures/canary/sglang-0.5.22/sglang/_version.py @@ -0,0 +1,24 @@ +# file generated by vcs-versioning +# don't change, don't track in version control +from __future__ import annotations + +__all__ = [ + "__version__", + "__version_tuple__", + "version", + "version_tuple", + "__commit_id__", + "commit_id", +] + +version: str +__version__: str +__version_tuple__: tuple[int | str, ...] +version_tuple: tuple[int | str, ...] +commit_id: str | None +__commit_id__: str | None + +__version__ = version = '0.5.22' +__version_tuple__ = version_tuple = (0, 5, 22) + +__commit_id__ = commit_id = None diff --git a/tests/fixtures/canary/sglang-0.5.22/sglang_cpu-0.5.22.dist-info/METADATA b/tests/fixtures/canary/sglang-0.5.22/sglang_cpu-0.5.22.dist-info/METADATA new file mode 100644 index 00000000..a682b52d --- /dev/null +++ b/tests/fixtures/canary/sglang-0.5.22/sglang_cpu-0.5.22.dist-info/METADATA @@ -0,0 +1,3 @@ +Metadata-Version: 2.4 +Name: sglang-cpu +Version: 0.5.22 diff --git a/tests/fixtures/canary/vllm-0.32.0/vllm-0.32.0+cpu.dist-info/METADATA b/tests/fixtures/canary/vllm-0.32.0/vllm-0.32.0+cpu.dist-info/METADATA new file mode 100644 index 00000000..e0d3b898 --- /dev/null +++ b/tests/fixtures/canary/vllm-0.32.0/vllm-0.32.0+cpu.dist-info/METADATA @@ -0,0 +1,3 @@ +Metadata-Version: 2.4 +Name: vllm +Version: 0.32.0+cpu diff --git a/tests/fixtures/canary/vllm-0.32.0/vllm/__init__.py b/tests/fixtures/canary/vllm-0.32.0/vllm/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/fixtures/canary/vllm-0.32.0/vllm/engine/arg_utils.py b/tests/fixtures/canary/vllm-0.32.0/vllm/engine/arg_utils.py new file mode 100644 index 00000000..ea628467 --- /dev/null +++ b/tests/fixtures/canary/vllm-0.32.0/vllm/engine/arg_utils.py @@ -0,0 +1,3223 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import argparse +import copy +import dataclasses +import functools +import json +import os +import sys +import warnings +from collections.abc import Callable +from dataclasses import MISSING, asdict, dataclass, fields, is_dataclass +from types import UnionType +from typing import ( + TYPE_CHECKING, + Annotated, + Any, + Literal, + TypeAlias, + TypeVar, + Union, + cast, + get_args, + get_origin, + is_typeddict, +) + +import huggingface_hub +import regex as re +import torch +from pydantic import TypeAdapter, ValidationError +from pydantic.fields import FieldInfo +from typing_extensions import TypeIs + +import vllm.envs as envs +from vllm.config import ( + AttentionConfig, + AuxOutputConfig, + CacheConfig, + CompilationConfig, + ConfigType, + DeviceConfig, + DiffusionConfig, + ECTransferConfig, + EncoderCacheManagerConfig, + EngramConfig, + EPLBConfig, + FaultToleranceConfig, + KernelConfig, + KVEventsConfig, + KVTransferConfig, + LoadConfig, + LoggingConfig, + LoRAConfig, + MambaConfig, + ModelConfig, + MultiModalConfig, + ObservabilityConfig, + OffloadConfig, + ParallelConfig, + PoolerConfig, + PrefetchOffloadConfig, + ProfilerConfig, + ReasoningConfig, + SchedulerConfig, + SpeculativeConfig, + StructuredOutputsConfig, + UVAOffloadConfig, + VllmConfig, + WeightTransferConfig, + get_attr_docs, +) +from vllm.config.cache import ( + CacheDType, + KVOffloadingBackend, + MambaCacheMode, + MambaDType, + PrefixCachingHashAlgo, +) +from vllm.config.device import Device +from vllm.config.kernel import ( + PASSTHROUGH_ALL2ALL_BACKEND, + IrOpPriorityConfig, + LinearBackend, + MoEBackend, + SparseIndexerTopkBackend, +) +from vllm.config.load import SafetensorsLoadStrategy +from vllm.config.logging import LogLevel +from vllm.config.lora import MaxLoRARanks +from vllm.config.mamba import MambaBackendEnum, MambaSSUAlgorithm +from vllm.config.model import ( + ConvertOption, + HfOverrides, + LogprobsMode, + ModelDType, + RunnerOption, + TokenizerMode, +) +from vllm.config.multimodal import ( + MMCacheType, + MMEncoderTPMode, + MMHasherAlgorithm, + MMProcessorDevice, + MMTensorIPC, +) +from vllm.config.observability import DetailedTraceModules +from vllm.config.parallel import ( + All2AllBackend, + DataParallelBackend, + DCPCommBackend, + DistributedExecutorBackend, + ExpertPlacementStrategy, +) +from vllm.config.scheduler import SchedulerPolicy +from vllm.config.utils import get_field +from vllm.config.vllm import OptimizationLevel, PerformanceMode +from vllm.config.watermarking import WatermarkConfig +from vllm.logger import configure_logging_if_needed, init_logger, suppress_logging +from vllm.platforms import CpuArchEnum, current_platform +from vllm.plugins import load_general_plugins +from vllm.ray.lazy_utils import is_in_ray_actor, is_ray_initialized +from vllm.transformers_utils.config import maybe_override_with_speculators +from vllm.transformers_utils.repo_utils import get_model_path +from vllm.transformers_utils.utils import is_cloud_storage +from vllm.utils.argparse_utils import ( + FlexibleArgumentParser, + human_readable_int, + human_readable_int_or_auto, +) +from vllm.utils.mem_constants import GiB_bytes +from vllm.utils.network_utils import get_ip +from vllm.utils.torch_utils import resolve_kv_cache_dtype_string +from vllm.v1.attention.backends.registry import AttentionBackendEnum +from vllm.v1.sample.logits_processor import LogitsProcessor +from vllm.version import __version__ as VLLM_VERSION + +if TYPE_CHECKING: + from vllm.config.quantization import QuantizationConfigArgs + from vllm.model_executor.layers.quantization import QuantizationMethods + from vllm.model_executor.model_loader import LoadFormats + from vllm.usage.usage_lib import UsageContext + from vllm.v1.executor import Executor +else: + Executor = Any + QuantizationMethods = str + LoadFormats = str + UsageContext = Any + + +logger = init_logger(__name__) + +# object is used to allow for special typing forms +T = TypeVar("T") +TypeHint: TypeAlias = type[Any] | object +TypeHintT: TypeAlias = type[T] | object + + +def parse_type(return_type: Callable[[str], T]) -> Callable[[str], T]: + def _parse_type(val: str) -> T: + try: + return return_type(val) + except ValueError as e: + raise argparse.ArgumentTypeError( + f"Value {val} cannot be converted to {return_type}." + ) from e + + return _parse_type + + +def optional_type(return_type: Callable[[str], T]) -> Callable[[str], T | None]: + def _optional_type(val: str) -> T | None: + if val == "" or val == "None": + return None + return parse_type(return_type)(val) + + return _optional_type + + +def union_dict_and_str(val: str) -> str | dict[str, str] | None: + if not re.match(r"(?s)^\s*{.*}\s*$", val): + return str(val) + return optional_type(json.loads)(val) + + +def is_type(type_hint: TypeHint, type: TypeHintT) -> TypeIs[TypeHintT]: + """Check if the type hint is a specific type.""" + return type_hint is type or get_origin(type_hint) is type + + +def is_dict_subclass(type_hint: TypeHint) -> bool: + """Check if the type hint is a subclass of `dict`. + + `TypedDict`s are excluded because they do not support class checks. + """ + return ( + isinstance(type_hint, type) + and not is_typeddict(type_hint) + and issubclass(type_hint, dict) + ) + + +def contains_type(type_hints: set[TypeHint], type: TypeHintT) -> bool: + """Check if the type hints contain a specific type.""" + return any(is_type(type_hint, type) for type_hint in type_hints) + + +def get_type(type_hints: set[TypeHint], type: TypeHintT) -> TypeHintT: + """Get the specific type from the type hints.""" + return next((th for th in type_hints if is_type(th, type)), None) + + +def literal_to_kwargs(type_hints: set[TypeHint]) -> dict[str, Any]: + """Get the `type` and `choices` from a `Literal` type hint in `type_hints`. + + If `type_hints` also contains `str`, we use `metavar` instead of `choices`. + """ + type_hint = get_type(type_hints, Literal) + options = get_args(type_hint) + option_type = type(options[0]) + if not all(isinstance(option, option_type) for option in options): + raise ValueError( + "All options must be of the same type. " + f"Got {options} with types {[type(c) for c in options]}" + ) + kwarg = "metavar" if contains_type(type_hints, str) else "choices" + return {"type": option_type, kwarg: sorted(options)} + + +def collection_to_kwargs(type_hints: set[TypeHint], type: TypeHint) -> dict[str, Any]: + type_hint = get_type(type_hints, type) + types = get_args(type_hint) + elem_type = types[0] + + # Handle Ellipsis + assert all(t is elem_type for t in types if t is not Ellipsis), ( + f"All non-Ellipsis elements must be of the same type. Got {types}." + ) + + # Handle Union types + if get_origin(elem_type) in {Union, UnionType}: + # Union for Union[X, Y] and UnionType for X | Y + assert str in get_args(elem_type), ( + "If element can have multiple types, one must be 'str' " + f"(i.e. 'list[int | str]'). Got {elem_type}." + ) + elem_type = str + + return { + "type": elem_type, + "nargs": "+" if type is not tuple or Ellipsis in types else len(types), + } + + +def is_not_builtin(type_hint: TypeHint) -> bool: + """Check if the class is not a built-in type.""" + return type_hint.__module__ != "builtins" + + +def get_type_hints(type_hint: TypeHint) -> set[TypeHint]: + """Extract type hints from Annotated or Union type hints.""" + type_hints: set[TypeHint] = set() + origin = get_origin(type_hint) + args = get_args(type_hint) + + if origin is Annotated: + type_hints.update(get_type_hints(args[0])) + elif origin in {Union, UnionType}: + # Union for Union[X, Y] and UnionType for X | Y + for arg in args: + type_hints.update(get_type_hints(arg)) + else: + type_hints.add(type_hint) + + return type_hints + + +NEEDS_HELP = ( + any("--help" in arg for arg in sys.argv) # vllm SUBCOMMAND --help + or "mkdocs" in sys.modules # mkdocs SUBCOMMAND +) + + +def _maybe_add_docs_url(cls: Any) -> str: + """Generate API docs URL for a vllm config class.""" + import vllm.config + + name = cls.__name__ + if getattr(vllm.config, name, None) is not cls: + return "" + version = f"v{VLLM_VERSION}" if "dev" not in VLLM_VERSION else "latest" + return f"\n\nAPI docs: https://docs.vllm.ai/en/{version}/api/vllm/config/#vllm.config.{name}" + + +def _expand_json_human_readable_numbers(val: str) -> str: + """Expand human-readable number suffixes in a JSON string. + + Based on :func:`human_readable_int` so that the ``k/m/g/t`` (decimal) and + ``K/M/G/T`` (binary) conventions work out the box. + Also works inside JSON config arguments such + as ``--kv-transfer-config '{"cpu_bytes_to_use": 80m}'``. + + Only bare (unquoted) tokens are replaced so that JSON string values + like ``"model_name"`` are never modified. + """ + # Split on quoted strings so we only touch non-string regions. + parts = re.split(r'("(?:[^"\\]|\\.)*")', val) + for i in range(0, len(parts), 2): # even indices = outside strings + parts[i] = re.sub( + r"\b\d+(?:\.\d+)?[kKmMgGtT]\b", + lambda m: str(human_readable_int(m.group())), + parts[i], + ) + return "".join(parts) + + +@functools.lru_cache(maxsize=30) +def _compute_kwargs(cls: ConfigType) -> dict[str, dict[str, Any]]: + # Save time only getting attr docs if we're generating help text + cls_docs = get_attr_docs(cls) if NEEDS_HELP else {} + kwargs = {} + for field in fields(cls): + # Get the set of possible types for the field + type_hints: set[TypeHint] = get_type_hints(field.type) + # Subclasses of dict (e.g. MultiModalDummyOptions) are CLI dicts + type_hints = {dict if is_dict_subclass(th) else th for th in type_hints} + + # If the field is a dataclass, we can use the model_validate_json + generator = (th for th in type_hints if is_dataclass(th)) + dataclass_cls = next(generator, None) + + # Get the default value of the field + if field.default is not MISSING: + default = field.default + # Handle pydantic.Field defaults + if isinstance(default, FieldInfo): + if default.default_factory is None: + default = default.default + else: + # VllmConfig's Fields have default_factory set to config classes. + # These could emit logs on init, which would be confusing. + with suppress_logging(): + default = default.default_factory() # type: ignore[call-arg] + elif field.default_factory is not MISSING: + default = field.default_factory() + + # Get the help text for the field + name = field.name + help = cls_docs.get(name, "").strip() + # Escape % for argparse + help = help.replace("%", "%%") + + # Initialise the kwargs dictionary for the field + kwargs[name] = {"default": default, "help": help} + + # Set other kwargs based on the type hints + json_tip = ( + "Should either be a valid JSON string or JSON keys passed individually." + ) + if dataclass_cls is not None: + + def parse_dataclass(val: str, cls=dataclass_cls) -> Any: + try: + val = _expand_json_human_readable_numbers(val) + return TypeAdapter(cls).validate_json(val) + except ValidationError as e: + raise argparse.ArgumentTypeError(repr(e)) from e + + kwargs[name]["type"] = parse_dataclass + kwargs[name]["help"] += _maybe_add_docs_url(dataclass_cls) + kwargs[name]["help"] += f"\n\n{json_tip}" + elif type_hints == {bool, str, type(None)}: + # Optional-valued flag: bare flag -> True, value -> str. + kwargs[name]["type"] = str + kwargs[name]["nargs"] = "?" + kwargs[name]["const"] = True + elif contains_type(type_hints, bool): + # Creates --no-<name> and --<name> flags + kwargs[name]["action"] = argparse.BooleanOptionalAction + elif contains_type(type_hints, Literal): + kwargs[name].update(literal_to_kwargs(type_hints)) + elif contains_type(type_hints, tuple): + kwargs[name].update(collection_to_kwargs(type_hints, tuple)) + elif contains_type(type_hints, list): + kwargs[name].update(collection_to_kwargs(type_hints, list)) + elif contains_type(type_hints, set): + kwargs[name].update(collection_to_kwargs(type_hints, set)) + elif contains_type(type_hints, int): + # Arguments that accept human-readable integer strings (e.g., 1K, 2M, 1G) + human_readable_int_args = { + "max_num_batched_tokens", + "max_num_scheduled_tokens", + "kv_cache_memory_bytes", + "safetensors_prefetch_block_size", + "max_num_queued_tokens", + } + if name == "max_model_len": + kwargs[name]["type"] = human_readable_int_or_auto + kwargs[name]["help"] += f"\n\n{human_readable_int_or_auto.__doc__}" + elif name in human_readable_int_args: + kwargs[name]["type"] = human_readable_int + kwargs[name]["help"] += f"\n\n{human_readable_int.__doc__}" + else: + kwargs[name]["type"] = int + elif contains_type(type_hints, float): + kwargs[name]["type"] = float + elif contains_type(type_hints, dict) and ( + contains_type(type_hints, str) + or any(is_not_builtin(th) for th in type_hints) + ): + kwargs[name]["type"] = union_dict_and_str + elif contains_type(type_hints, dict): + kwargs[name]["type"] = parse_type(json.loads) + kwargs[name]["help"] += f"\n\n{json_tip}" + elif contains_type(type_hints, str) or any( + is_not_builtin(th) for th in type_hints + ): + kwargs[name]["type"] = str + else: + raise ValueError(f"Unsupported type {type_hints} for argument {name}.") + + # If the type hint was a sequence of literals, use the helper function + # to update the type and choices + if get_origin(kwargs[name].get("type")) is Literal: + kwargs[name].update(literal_to_kwargs({kwargs[name]["type"]})) + + # If None is in type_hints, make the argument optional. + # But not if it's a bool, argparse will handle this better. + if type(None) in type_hints and not contains_type(type_hints, bool): + kwargs[name]["type"] = optional_type(kwargs[name]["type"]) + if kwargs[name].get("choices"): + kwargs[name]["choices"].append(None) + return kwargs + + +def get_kwargs(cls: ConfigType) -> dict[str, dict[str, Any]]: + """Return argparse kwargs for the given Config dataclass. + + If `--help` or `mkdocs` are not present in the command line command, the + attribute documentation will not be included in the help output. + + The heavy computation is cached via functools.lru_cache, and a deep copy + is returned so callers can mutate the dictionary without affecting the + cached version. + """ + return copy.deepcopy(_compute_kwargs(cls)) + + +_LOG_CONFIG_FILE_DEPRECATION_MESSAGE = ( + "--log-config-file is deprecated and will be removed in v0.33.0. " + "Use --logging-config.pylogging_config_file instead." +) + + +_DISABLE_CASCADE_ATTN_DEPRECATION_MESSAGE = ( + "--disable-cascade-attn is deprecated and has no effect since cascade " + "attention has been removed. It will be removed in v0.33.0." +) + + +class DeprecatedLogConfigFileAction(argparse.Action): + """Warn when the legacy ``--log-config-file`` option is used.""" + + def __call__(self, parser, namespace, values, option_string=None): + warnings.warn(_LOG_CONFIG_FILE_DEPRECATION_MESSAGE, stacklevel=1) + setattr(namespace, self.dest, values) + + +@dataclass +class EngineArgs: + """Arguments for vLLM engine.""" + + model: str = ModelConfig.model + # Public compatibility argument. Canonical runtime state lives in + # AuxOutputConfig. + enable_return_routed_experts: bool = False + return_sampling_mask: bool = ModelConfig.return_sampling_mask + model_weights: str = ModelConfig.model_weights + served_model_name: str | list[str] | None = ModelConfig.served_model_name + tokenizer: str | None = ModelConfig.tokenizer + hf_config_path: str | None = ModelConfig.hf_config_path + runner: RunnerOption = ModelConfig.runner + convert: ConvertOption = ModelConfig.convert + skip_tokenizer_init: bool = ModelConfig.skip_tokenizer_init + enable_prompt_embeds: bool = ModelConfig.enable_prompt_embeds + tokenizer_mode: TokenizerMode | str = ModelConfig.tokenizer_mode + trust_remote_code: bool = ModelConfig.trust_remote_code + allowed_local_media_path: str = ModelConfig.allowed_local_media_path + allowed_media_domains: list[str] | None = ModelConfig.allowed_media_domains + download_dir: str | None = LoadConfig.download_dir + safetensors_load_strategy: SafetensorsLoadStrategy | None = ( + LoadConfig.safetensors_load_strategy + ) + safetensors_prefetch_num_threads: int = LoadConfig.safetensors_prefetch_num_threads + safetensors_prefetch_block_size: int = LoadConfig.safetensors_prefetch_block_size + load_format: str | LoadFormats = LoadConfig.load_format + config_format: str = ModelConfig.config_format + dtype: ModelDType = ModelConfig.dtype + kv_cache_dtype: CacheDType = CacheConfig.cache_dtype + seed: int = ModelConfig.seed + max_model_len: int = ModelConfig.max_model_len + cudagraph_capture_sizes: list[int] | None = ( + CompilationConfig.cudagraph_capture_sizes + ) + max_cudagraph_capture_size: int | None = get_field( + CompilationConfig, "max_cudagraph_capture_size" + ) + ir_op_priority: IrOpPriorityConfig = get_field(KernelConfig, "ir_op_priority") + # Note: Specifying a custom executor backend by passing a class + # is intended for expert use only. The API may change without + # notice. + distributed_executor_backend: ( + str | DistributedExecutorBackend | type[Executor] | None + ) = ParallelConfig.distributed_executor_backend + # number of P/D disaggregation (or other disaggregation) workers + pipeline_parallel_size: int = ParallelConfig.pipeline_parallel_size + master_addr: str = ParallelConfig.master_addr + master_port: int = ParallelConfig.master_port + nnodes: int = ParallelConfig.nnodes + node_rank: int = ParallelConfig.node_rank + distributed_timeout_seconds: int | None = ParallelConfig.distributed_timeout_seconds + cpu_distributed_timeout_seconds: int | None = ( + ParallelConfig.cpu_distributed_timeout_seconds + ) + numa_bind: bool = ParallelConfig.numa_bind + numa_bind_nodes: list[int] | None = ParallelConfig.numa_bind_nodes + numa_bind_cpus: list[str] | None = ParallelConfig.numa_bind_cpus + device_ids: list[int | str] | None = None + tensor_parallel_size: int = ParallelConfig.tensor_parallel_size + prefill_context_parallel_size: int = ParallelConfig.prefill_context_parallel_size + decode_context_parallel_size: int = ParallelConfig.decode_context_parallel_size + dcp_comm_backend: DCPCommBackend | None = ParallelConfig.dcp_comm_backend + dcp_q_replicate: bool | None = ParallelConfig.dcp_q_replicate + dcp_kv_cache_interleave_size: int = ParallelConfig.dcp_kv_cache_interleave_size + cp_kv_cache_interleave_size: int | None = None + data_parallel_size: int = ParallelConfig.data_parallel_size + data_parallel_rank: int | None = None + data_parallel_start_rank: int | None = None + data_parallel_size_local: int | None = None + data_parallel_address: str | None = None + data_parallel_rpc_port: int | None = None + data_parallel_hybrid_lb: bool = False + data_parallel_external_lb: bool = False + data_parallel_multi_port_external_lb: bool = False + data_parallel_backend: DataParallelBackend = ParallelConfig.data_parallel_backend + enable_hc_sp: bool = ParallelConfig.enable_hc_sp + enable_expert_parallel: bool = ParallelConfig.enable_expert_parallel + enable_batch_sharded_sampling: bool | None = ( + ParallelConfig.enable_batch_sharded_sampling + ) + enable_ep_weight_filter: bool = ParallelConfig.enable_ep_weight_filter + moe_backend: MoEBackend = KernelConfig.moe_backend + linear_backend: LinearBackend = KernelConfig.linear_backend + sparse_indexer_topk_backend: SparseIndexerTopkBackend = ( + KernelConfig.sparse_indexer_topk_backend + ) + all2all_backend: All2AllBackend = ParallelConfig.all2all_backend + enable_elastic_ep: bool = ParallelConfig.enable_elastic_ep + elastic_ep_max_dp_size: int = ParallelConfig.elastic_ep_max_dp_size + enable_dbo: bool = ParallelConfig.enable_dbo + ubatch_size: int = ParallelConfig.ubatch_size + dbo_decode_token_threshold: int = ParallelConfig.dbo_decode_token_threshold + dp_sync_interval: int = ParallelConfig.dp_sync_interval + dbo_prefill_token_threshold: int = ParallelConfig.dbo_prefill_token_threshold + disable_nccl_for_dp_synchronization: bool | None = ( + ParallelConfig.disable_nccl_for_dp_synchronization + ) + eplb_config: EPLBConfig = get_field(ParallelConfig, "eplb_config") + enable_eplb: bool = ParallelConfig.enable_eplb + expert_placement_strategy: ExpertPlacementStrategy = ( + ParallelConfig.expert_placement_strategy + ) + _api_process_count: int = ParallelConfig._api_process_count + _api_process_rank: int = ParallelConfig._api_process_rank + max_parallel_loading_workers: int | None = ( + ParallelConfig.max_parallel_loading_workers + ) + block_size: int | None = None + enable_prefix_caching: bool | None = None + prefix_caching_hash_algo: PrefixCachingHashAlgo = ( + CacheConfig.prefix_caching_hash_algo + ) + prefix_cache_retention_interval: int | None = get_field( + CacheConfig, "prefix_cache_retention_interval" + ) + disable_sliding_window: bool = ModelConfig.disable_sliding_window + disable_cascade_attn: bool | None = None + offload_backend: str = OffloadConfig.offload_backend + cpu_offload_gb: float = UVAOffloadConfig.cpu_offload_gb + cpu_offload_params: set[str] = get_field(UVAOffloadConfig, "cpu_offload_params") + offload_group_size: int = PrefetchOffloadConfig.offload_group_size + offload_num_in_group: int = PrefetchOffloadConfig.offload_num_in_group + offload_prefetch_step: int = PrefetchOffloadConfig.offload_prefetch_step + offload_params: set[str] = get_field(PrefetchOffloadConfig, "offload_params") + gpu_memory_utilization: float = CacheConfig.gpu_memory_utilization + kv_cache_memory_bytes: int | None = CacheConfig.kv_cache_memory_bytes + max_num_batched_tokens: int | None = None + max_num_scheduled_tokens: int | None = None + long_prefill_token_threshold: int = SchedulerConfig.long_prefill_token_threshold + long_prefill_token_threshold_adaptive: bool = ( + SchedulerConfig.long_prefill_token_threshold_adaptive + ) + max_num_seqs: int | None = None + max_num_active_seqs: int | None = SchedulerConfig.max_num_active_seqs + max_num_queued_reqs: int | None = None + max_num_queued_tokens: int | None = None + max_logprobs: int = ModelConfig.max_logprobs + logprobs_mode: LogprobsMode = ModelConfig.logprobs_mode + use_fp64_gumbel: bool = ModelConfig.use_fp64_gumbel + enable_trace_replay: bool = ModelConfig.enable_trace_replay + disable_log_stats: bool = False + aggregate_engine_logging: bool = False + revision: str | None = ModelConfig.revision + code_revision: str | None = ModelConfig.code_revision + hf_token: bool | str | None = ModelConfig.hf_token + hf_overrides: HfOverrides = get_field(ModelConfig, "hf_overrides") + model_class_overrides: dict[str, str] = get_field( + ModelConfig, "model_class_overrides" + ) + tokenizer_revision: str | None = ModelConfig.tokenizer_revision + quantization: QuantizationMethods | str | None = ModelConfig.quantization + quantization_config: "dict[str, Any] | QuantizationConfigArgs | None" = None + """User-facing quantization configuration. Carries per-layer-kind + QuantSpecs (linear, moe) and ignore patterns; see + :class:`QuantizationConfigArgs`. Auto-populated from the matching online + shorthand when `quantization` is one of the values in + `ONLINE_QUANT_SHORTHAND_NAMES`.""" + allow_deprecated_quantization: bool = ModelConfig.allow_deprecated_quantization + enforce_eager: bool = ModelConfig.enforce_eager + disable_custom_all_reduce: bool = ParallelConfig.disable_custom_all_reduce + enable_shm_tensor_arena: bool = ParallelConfig.enable_shm_tensor_arena + language_model_only: bool = MultiModalConfig.language_model_only + limit_mm_per_prompt: dict[str, int | dict[str, int]] = get_field( + MultiModalConfig, "limit_per_prompt" + ) + enable_mm_embeds: bool = MultiModalConfig.enable_mm_embeds + interleave_mm_strings: bool = MultiModalConfig.interleave_mm_strings + media_io_kwargs: dict[str, dict[str, Any]] = get_field( + MultiModalConfig, "media_io_kwargs" + ) + mm_processor_kwargs: dict[str, Any] | None = MultiModalConfig.mm_processor_kwargs + mm_processor_cache_gb: float = MultiModalConfig.mm_processor_cache_gb + mm_processor_cache_type: MMCacheType | None = ( + MultiModalConfig.mm_processor_cache_type + ) + mm_hasher_algorithm: MMHasherAlgorithm = get_field( + MultiModalConfig, "mm_hasher_algorithm" + ) + mm_shm_cache_max_object_size_mb: int = ( + MultiModalConfig.mm_shm_cache_max_object_size_mb + ) + mm_encoder_only: bool = MultiModalConfig.mm_encoder_only + mm_encoder_tp_mode: MMEncoderTPMode = MultiModalConfig.mm_encoder_tp_mode + mm_encoder_attn_backend: AttentionBackendEnum | str | None = ( + MultiModalConfig.mm_encoder_attn_backend + ) + mm_encoder_attn_dtype: str | None = MultiModalConfig.mm_encoder_attn_dtype + mm_encoder_fp8_scale_path: str | None = MultiModalConfig.mm_encoder_fp8_scale_path + mm_encoder_fp8_scale_save_path: str | None = ( + MultiModalConfig.mm_encoder_fp8_scale_save_path + ) + mm_encoder_fp8_scale_save_margin: float = ( + MultiModalConfig.mm_encoder_fp8_scale_save_margin + ) + io_processor_plugin: str | None = None + renderer_num_workers: int = 1 + skip_mm_profiling: bool = MultiModalConfig.skip_mm_profiling + video_pruning_rate: float | None = MultiModalConfig.video_pruning_rate + video_pruning_method: str = MultiModalConfig.video_pruning_method + mm_tensor_ipc: MMTensorIPC = MultiModalConfig.mm_tensor_ipc + mm_processor_device: MMProcessorDevice = "auto" + mm_ipc_gpu_memory_gb: float = MultiModalConfig.mm_ipc_gpu_memory_gb + mm_device_do_normalize: bool | None = MultiModalConfig.mm_device_do_normalize + # LoRA fields + enable_lora: bool = False + max_loras: int = LoRAConfig.max_loras + max_lora_rank: MaxLoRARanks = LoRAConfig.max_lora_rank + default_mm_loras: dict[str, str] | None = LoRAConfig.default_mm_loras + fully_sharded_loras: bool = LoRAConfig.fully_sharded_loras + max_cpu_loras: int | None = LoRAConfig.max_cpu_loras + lora_dtype: str | torch.dtype | None = LoRAConfig.lora_dtype + lora_target_modules: list[str] | None = LoRAConfig.target_modules + enable_tower_connector_lora: bool = LoRAConfig.enable_tower_connector_lora + specialize_active_lora: bool = LoRAConfig.specialize_active_lora + enable_mixed_moe_lora_format: bool = LoRAConfig.enable_mixed_moe_lora_format + enable_moe_shared_loras: bool = LoRAConfig.enable_moe_shared_loras + max_lora_cls_labels: int | None = LoRAConfig.max_lora_cls_labels + + ray_workers_use_nsight: bool = ParallelConfig.ray_workers_use_nsight + num_gpu_blocks_override: int | None = CacheConfig.num_gpu_blocks_override + model_loader_extra_config: dict = get_field(LoadConfig, "model_loader_extra_config") + ignore_patterns: str | list[str] = get_field(LoadConfig, "ignore_patterns") + + enable_chunked_prefill: bool | None = None + disable_chunked_mm_input: bool = SchedulerConfig.disable_chunked_mm_input + + scheduler_reserve_full_isl: bool = SchedulerConfig.scheduler_reserve_full_isl + prefill_schedule_interval: int = SchedulerConfig.prefill_schedule_interval + + watermark: float = SchedulerConfig.watermark + + disable_hybrid_kv_cache_manager: bool | None = ( + SchedulerConfig.disable_hybrid_kv_cache_manager + ) + + structured_outputs_config: StructuredOutputsConfig = get_field( + VllmConfig, "structured_outputs_config" + ) + aux_output_config: AuxOutputConfig = get_field(VllmConfig, "aux_output_config") + reasoning_parser: str = StructuredOutputsConfig.reasoning_parser + reasoning_parser_plugin: str | None = None + + speculative_config: dict[str, Any] | None = None + spec_method: str | None = None + spec_model: str | None = None + spec_tokens: int | None = None + watermark_config: dict[str, Any] | None = None + diffusion_config: dict[str, Any] | None = None + + show_hidden_metrics_for_version: str | None = ( + ObservabilityConfig.show_hidden_metrics_for_version + ) + otlp_traces_endpoint: str | None = ObservabilityConfig.otlp_traces_endpoint + collect_detailed_traces: list[DetailedTraceModules] | None = ( + ObservabilityConfig.collect_detailed_traces + ) + per_request_spec_decode_metrics: Literal["none", "summary", "detailed"] = ( + ObservabilityConfig.per_request_spec_decode_metrics + ) + kv_cache_metrics: bool = ObservabilityConfig.kv_cache_metrics + kv_cache_metrics_sample: float = get_field( + ObservabilityConfig, "kv_cache_metrics_sample" + ) + custom_histogram_buckets: dict[str, list[float]] | None = ( + ObservabilityConfig.custom_histogram_buckets + ) + cudagraph_metrics: bool = ObservabilityConfig.cudagraph_metrics + enable_layerwise_nvtx_tracing: bool = ( + ObservabilityConfig.enable_layerwise_nvtx_tracing + ) + enable_mfu_metrics: bool = ObservabilityConfig.enable_mfu_metrics + enable_logging_iteration_details: bool = ( + ObservabilityConfig.enable_logging_iteration_details + ) + jit_monitor_mode: Literal["warn", "error"] = ObservabilityConfig.jit_monitor_mode + jit_monitor_verbose: bool = ObservabilityConfig.jit_monitor_verbose + enable_mm_processor_stats: bool = ObservabilityConfig.enable_mm_processor_stats + scheduling_policy: SchedulerPolicy = SchedulerConfig.policy + scheduler_cls: str | type[object] | None = SchedulerConfig.scheduler_cls + + pooler_config: PoolerConfig | None = ModelConfig.pooler_config + compilation_config: CompilationConfig = get_field(VllmConfig, "compilation_config") + attention_config: AttentionConfig = get_field(VllmConfig, "attention_config") + engram_config: EngramConfig | None = VllmConfig.engram_config + mamba_config: MambaConfig = get_field(VllmConfig, "mamba_config") + kernel_config: KernelConfig = get_field(VllmConfig, "kernel_config") + enable_flashinfer_autotune: bool = get_field( + KernelConfig, "enable_flashinfer_autotune" + ) + worker_cls: str = ParallelConfig.worker_cls + worker_extension_cls: str = ParallelConfig.worker_extension_cls + + profiler_config: ProfilerConfig = get_field(VllmConfig, "profiler_config") + + logging_config: LoggingConfig | None = None + log_level: LogLevel | None = None + log_config_file: str | None = None + + kv_transfer_config: KVTransferConfig | None = None + kv_events_config: KVEventsConfig | None = None + + ec_transfer_config: ECTransferConfig | None = None + ec_manager_config: EncoderCacheManagerConfig = get_field( + VllmConfig, "ec_manager_config" + ) + reasoning_config: ReasoningConfig = get_field(VllmConfig, "reasoning_config") + + generation_config: str = ModelConfig.generation_config + enable_sleep_mode: bool = ModelConfig.enable_sleep_mode + sleep_mode_offload_cudagraph: bool = ModelConfig.sleep_mode_offload_cudagraph + sleep_preserve_parameter_names: list[str] = get_field( + ModelConfig, "sleep_preserve_parameter_names" + ) + enable_cumem_allocator: bool = ModelConfig.enable_cumem_allocator + enable_nccl_comm_suspend: bool = ModelConfig.enable_nccl_comm_suspend + override_generation_config: dict[str, Any] = get_field( + ModelConfig, "override_generation_config" + ) + model_impl: str = ModelConfig.model_impl + attention_backend: AttentionBackendEnum | None = AttentionConfig.backend + + kv_cache_dtype_skip_layers: list[str] = get_field( + CacheConfig, "kv_cache_dtype_skip_layers" + ) + mamba_cache_dtype: MambaDType = CacheConfig.mamba_cache_dtype + mamba_ssm_cache_dtype: MambaDType = CacheConfig.mamba_ssm_cache_dtype + mamba_block_size: int | None = get_field(CacheConfig, "mamba_block_size") + prefix_match_unit: int | None = get_field(CacheConfig, "prefix_match_unit") + mamba_cache_mode: MambaCacheMode = CacheConfig.mamba_cache_mode + enable_mamba_shared_prefix_checkpoint: bool = ( + CacheConfig.enable_mamba_shared_prefix_checkpoint + ) + replayssm_buffer_len: int = CacheConfig.replayssm_buffer_len + use_replayssm: bool = CacheConfig.use_replayssm + + mamba_backend: MambaBackendEnum = MambaBackendEnum.TRITON + mamba_ssu_algorithm: MambaSSUAlgorithm | None = None + enable_mamba_cache_stochastic_rounding: bool = ( + MambaConfig.enable_stochastic_rounding + ) + mamba_cache_philox_rounds: int = MambaConfig.stochastic_rounding_philox_rounds + + additional_config: dict[str, Any] = get_field(VllmConfig, "additional_config") + + use_tqdm_on_load: bool = LoadConfig.use_tqdm_on_load + pt_load_map_location: str | dict[str, str] = LoadConfig.pt_load_map_location + + logits_processors: list[str | type[LogitsProcessor]] | None = ( + ModelConfig.logits_processors + ) + """Custom logitproc types""" + + async_scheduling: bool | None = SchedulerConfig.async_scheduling + + stream_interval: int = SchedulerConfig.stream_interval + + kv_sharing_fast_prefill: bool = CacheConfig.kv_sharing_fast_prefill + swa_bounded_replay: bool = CacheConfig.swa_bounded_replay + optimization_level: OptimizationLevel = VllmConfig.optimization_level + performance_mode: PerformanceMode = VllmConfig.performance_mode + + fault_tolerance_config: FaultToleranceConfig = get_field( + ParallelConfig, "fault_tolerance_config" + ) + enable_fault_tolerance: bool = ParallelConfig.enable_fault_tolerance + + kv_offloading_size: float | None = CacheConfig.kv_offloading_size + kv_offloading_backend: KVOffloadingBackend = CacheConfig.kv_offloading_backend + tokens_only: bool = False + + shutdown_timeout: int = 0 + + weight_transfer_config: WeightTransferConfig | None = get_field( + VllmConfig, + "weight_transfer_config", + ) + + fail_on_environ_validation: bool = False + gdn_prefill_backend: ( + Literal["flashinfer", "triton", "cutedsl", "aiter_flydsl"] | None + ) = None + kda_prefill_backend: ( + Literal["auto", "triton", "flashkda", "flashinfer", "fused"] | None + ) = None + kda_decode_backend: Literal["auto", "native", "flashinfer", "triton"] | None = None + + def __post_init__(self): + # support `EngineArgs(compilation_config={...})` + # without having to manually construct a + # CompilationConfig object + if isinstance(self.compilation_config, dict): + self.compilation_config = CompilationConfig(**self.compilation_config) + if isinstance(self.attention_config, dict): + self.attention_config = AttentionConfig(**self.attention_config) + if isinstance(self.aux_output_config, dict): + self.aux_output_config = AuxOutputConfig(**self.aux_output_config) + if self.enable_return_routed_experts: + self.aux_output_config.enable_return_routed_experts = True + if isinstance(self.engram_config, dict): + self.engram_config = EngramConfig(**self.engram_config) + if isinstance(self.mamba_config, dict): + self.mamba_config = MambaConfig(**self.mamba_config) + if isinstance(self.kernel_config, dict): + self.kernel_config = KernelConfig(**self.kernel_config) + if isinstance(self.ec_manager_config, dict): + self.ec_manager_config = EncoderCacheManagerConfig(**self.ec_manager_config) + if isinstance(self.eplb_config, dict): + self.eplb_config = EPLBConfig(**self.eplb_config) + if isinstance(self.weight_transfer_config, dict): + self.weight_transfer_config = WeightTransferConfig( + **self.weight_transfer_config + ) + if isinstance(self.fault_tolerance_config, dict): + if not self.enable_fault_tolerance: + logger.warning( + "--fault-tolerance-config was passed. Fault tolerance is being " + "automatically enabled." + ) + self.enable_fault_tolerance = True + self.fault_tolerance_config = FaultToleranceConfig( + **self.fault_tolerance_config + ) + if isinstance(self.ir_op_priority, dict): + self.ir_op_priority = IrOpPriorityConfig(**self.ir_op_priority) + + from vllm.config.quantization import resolve_quantization_config + + self.quantization_config = resolve_quantization_config( + self.quantization, self.quantization_config + ) + + # Setup plugins + from vllm.plugins import load_general_plugins + + load_general_plugins() + # when use hf offline,replace model and tokenizer id to local model path + if huggingface_hub.constants.HF_HUB_OFFLINE: + # Skip cloud storage URIs (s3://, gs://, az://) — they are not + # HF repo IDs and will be resolved later by + # ModelConfig.maybe_pull_model_tokenizer_for_runai(). + if not is_cloud_storage(self.model): + model_id = self.model + self.model = get_model_path(self.model, self.revision) + if model_id is not self.model: + logger.info( + "HF_HUB_OFFLINE is True, replace model_id " + "[%s] to model_path [%s]", + model_id, + self.model, + ) + if self.tokenizer is not None and not is_cloud_storage(self.tokenizer): + tokenizer_id = self.tokenizer + self.tokenizer = get_model_path(self.tokenizer, self.tokenizer_revision) + if tokenizer_id is not self.tokenizer: + logger.info( + "HF_HUB_OFFLINE is True, replace tokenizer_id [%s] " + "to tokenizer_path [%s]", + tokenizer_id, + self.tokenizer, + ) + + @staticmethod + def add_cli_args(parser: FlexibleArgumentParser) -> FlexibleArgumentParser: + """Shared CLI arguments for vLLM engine.""" + # Model arguments + model_kwargs = get_kwargs(ModelConfig) + aux_output_kwargs = get_kwargs(AuxOutputConfig) + model_group = parser.add_argument_group( + title="ModelConfig", + description=ModelConfig.__doc__, + ) + if not ("serve" in sys.argv[1:] and "--help" in sys.argv[1:]): + model_group.add_argument("--model", **model_kwargs["model"]) + model_group.add_argument("--runner", **model_kwargs["runner"]) + model_group.add_argument("--convert", **model_kwargs["convert"]) + model_group.add_argument("--tokenizer", **model_kwargs["tokenizer"]) + model_group.add_argument("--tokenizer-mode", **model_kwargs["tokenizer_mode"]) + model_group.add_argument( + "--trust-remote-code", **model_kwargs["trust_remote_code"] + ) + model_group.add_argument("--dtype", **model_kwargs["dtype"]) + model_group.add_argument("--seed", **model_kwargs["seed"]) + model_group.add_argument("--hf-config-path", **model_kwargs["hf_config_path"]) + model_group.add_argument( + "--allowed-local-media-path", **model_kwargs["allowed_local_media_path"] + ) + model_group.add_argument( + "--allowed-media-domains", **model_kwargs["allowed_media_domains"] + ) + model_group.add_argument("--revision", **model_kwargs["revision"]) + model_group.add_argument("--code-revision", **model_kwargs["code_revision"]) + model_group.add_argument( + "--tokenizer-revision", **model_kwargs["tokenizer_revision"] + ) + model_group.add_argument("--max-model-len", **model_kwargs["max_model_len"]) + model_group.add_argument("--quantization", "-q", **model_kwargs["quantization"]) + model_group.add_argument( + "--quantization-config", **model_kwargs["quantization_config"] + ) + model_group.add_argument( + "--allow-deprecated-quantization", + **model_kwargs["allow_deprecated_quantization"], + ) + model_group.add_argument("--enforce-eager", **model_kwargs["enforce_eager"]) + model_group.add_argument( + "--enable-return-routed-experts", + **aux_output_kwargs["enable_return_routed_experts"], + ) + model_group.add_argument( + "--return-sampling-mask", + **model_kwargs["return_sampling_mask"], + ) + model_group.add_argument("--max-logprobs", **model_kwargs["max_logprobs"]) + model_group.add_argument("--logprobs-mode", **model_kwargs["logprobs_mode"]) + model_group.add_argument("--use-fp64-gumbel", **model_kwargs["use_fp64_gumbel"]) + model_group.add_argument( + "--enable-trace-replay", **model_kwargs["enable_trace_replay"] + ) + model_group.add_argument( + "--disable-sliding-window", **model_kwargs["disable_sliding_window"] + ) + model_group.add_argument( + "--disable-cascade-attn", + action=argparse.BooleanOptionalAction, + default=None, + help=_DISABLE_CASCADE_ATTN_DEPRECATION_MESSAGE, + ) + model_group.add_argument( + "--skip-tokenizer-init", **model_kwargs["skip_tokenizer_init"] + ) + model_group.add_argument( + "--enable-prompt-embeds", **model_kwargs["enable_prompt_embeds"] + ) + model_group.add_argument( + "--served-model-name", **model_kwargs["served_model_name"] + ) + model_group.add_argument("--config-format", **model_kwargs["config_format"]) + model_group.add_argument("--hf-token", **model_kwargs["hf_token"]) + model_group.add_argument("--hf-overrides", **model_kwargs["hf_overrides"]) + model_group.add_argument( + "--model-class-overrides", **model_kwargs["model_class_overrides"] + ) + model_group.add_argument("--pooler-config", **model_kwargs["pooler_config"]) + model_group.add_argument( + "--generation-config", **model_kwargs["generation_config"] + ) + model_group.add_argument( + "--override-generation-config", **model_kwargs["override_generation_config"] + ) + model_group.add_argument( + "--enable-sleep-mode", **model_kwargs["enable_sleep_mode"] + ) + model_group.add_argument( + "--sleep-mode-offload-cudagraph", + **model_kwargs["sleep_mode_offload_cudagraph"], + ) + model_group.add_argument( + "--sleep-preserve-parameter-names", + **model_kwargs["sleep_preserve_parameter_names"], + ) + model_group.add_argument( + "--enable-cumem-allocator", **model_kwargs["enable_cumem_allocator"] + ) + model_group.add_argument( + "--enable-nccl-comm-suspend", + **model_kwargs["enable_nccl_comm_suspend"], + ) + model_group.add_argument("--model-impl", **model_kwargs["model_impl"]) + model_group.add_argument( + "--logits-processors", **model_kwargs["logits_processors"] + ) + model_group.add_argument( + "--io-processor-plugin", **model_kwargs["io_processor_plugin"] + ) + model_group.add_argument( + "--renderer-num-workers", + **model_kwargs["renderer_num_workers"], + ) + + # Model loading arguments + load_kwargs = get_kwargs(LoadConfig) + load_group = parser.add_argument_group( + title="LoadConfig", + description=LoadConfig.__doc__, + ) + load_group.add_argument("--load-format", **load_kwargs["load_format"]) + load_group.add_argument("--download-dir", **load_kwargs["download_dir"]) + load_group.add_argument( + "--safetensors-load-strategy", **load_kwargs["safetensors_load_strategy"] + ) + load_group.add_argument( + "--safetensors-prefetch-num-threads", + **load_kwargs["safetensors_prefetch_num_threads"], + ) + load_group.add_argument( + "--safetensors-prefetch-block-size", + **load_kwargs["safetensors_prefetch_block_size"], + ) + load_group.add_argument( + "--model-loader-extra-config", **load_kwargs["model_loader_extra_config"] + ) + load_group.add_argument("--ignore-patterns", **load_kwargs["ignore_patterns"]) + load_group.add_argument("--use-tqdm-on-load", **load_kwargs["use_tqdm_on_load"]) + load_group.add_argument( + "--pt-load-map-location", **load_kwargs["pt_load_map_location"] + ) + + # Attention arguments + attention_kwargs = get_kwargs(AttentionConfig) + attention_group = parser.add_argument_group( + title="AttentionConfig", + description=AttentionConfig.__doc__, + ) + attention_group.add_argument( + "--attention-backend", **attention_kwargs["backend"] + ) + + # Mamba arguments + mamba_kwargs = get_kwargs(MambaConfig) + mamba_group = parser.add_argument_group( + title="MambaConfig", + description=MambaConfig.__doc__, + ) + mamba_group.add_argument("--mamba-backend", **mamba_kwargs["backend"]) + mamba_group.add_argument( + "--mamba-ssu-algorithm", **mamba_kwargs["ssu_algorithm"] + ) + mamba_group.add_argument( + "--enable-mamba-cache-stochastic-rounding", + **mamba_kwargs["enable_stochastic_rounding"], + ) + mamba_group.add_argument( + "--mamba-cache-philox-rounds", + **mamba_kwargs["stochastic_rounding_philox_rounds"], + ) + + # Structured outputs arguments + structured_outputs_kwargs = get_kwargs(StructuredOutputsConfig) + structured_outputs_group = parser.add_argument_group( + title="StructuredOutputsConfig", + description=StructuredOutputsConfig.__doc__, + ) + structured_outputs_group.add_argument( + "--reasoning-parser", + # Choices need to be validated after parsing to include plugins + **structured_outputs_kwargs["reasoning_parser"], + ) + structured_outputs_group.add_argument( + "--reasoning-parser-plugin", + **structured_outputs_kwargs["reasoning_parser_plugin"], + ) + + # Parallel arguments + parallel_kwargs = get_kwargs(ParallelConfig) + # Bound from --moe-backend in KernelConfig.set_platform_defaults(). + parallel_kwargs["all2all_backend"]["choices"].remove( + PASSTHROUGH_ALL2ALL_BACKEND + ) + parallel_group = parser.add_argument_group( + title="ParallelConfig", + description=ParallelConfig.__doc__, + ) + parallel_group.add_argument( + "--distributed-executor-backend", + **parallel_kwargs["distributed_executor_backend"], + ) + parallel_group.add_argument( + "--pipeline-parallel-size", + "-pp", + **parallel_kwargs["pipeline_parallel_size"], + ) + parallel_group.add_argument("--master-addr", **parallel_kwargs["master_addr"]) + parallel_group.add_argument("--master-port", **parallel_kwargs["master_port"]) + parallel_group.add_argument("--nnodes", "-n", **parallel_kwargs["nnodes"]) + parallel_group.add_argument("--node-rank", "-r", **parallel_kwargs["node_rank"]) + parallel_group.add_argument( + "--distributed-timeout-seconds", + **parallel_kwargs["distributed_timeout_seconds"], + ) + parallel_group.add_argument( + "--cpu-distributed-timeout-seconds", + **parallel_kwargs["cpu_distributed_timeout_seconds"], + ) + parallel_group.add_argument("--numa-bind", **parallel_kwargs["numa_bind"]) + parallel_group.add_argument( + "--numa-bind-nodes", **parallel_kwargs["numa_bind_nodes"] + ) + parallel_group.add_argument( + "--numa-bind-cpus", **parallel_kwargs["numa_bind_cpus"] + ) + parallel_group.add_argument( + "--device-ids", + type=lambda s: [ + int(device_id) if device_id.isdigit() else device_id + for device_id in (part.strip() for part in s.split(",")) + ], + default=None, + help="Comma-separated physical GPU device IDs or UUIDs to use " + '(e.g. --device-ids "2,3,5,7"). Avoids setting ' + "CUDA_VISIBLE_DEVICES, preserving full GPU topology " + "visibility for GPU-NIC affinity and DeepGEMM. " + "Note: has no effect with Ray executors; use Ray " + "placement groups for GPU selection instead.", + ) + parallel_group.add_argument( + "--tensor-parallel-size", "-tp", **parallel_kwargs["tensor_parallel_size"] + ) + parallel_group.add_argument( + "--decode-context-parallel-size", + "-dcp", + **parallel_kwargs["decode_context_parallel_size"], + ) + parallel_group.add_argument( + "--dcp-comm-backend", + **parallel_kwargs["dcp_comm_backend"], + ) + parallel_group.add_argument( + "--dcp-q-replicate", + **parallel_kwargs["dcp_q_replicate"], + ) + parallel_group.add_argument( + "--dcp-kv-cache-interleave-size", + **parallel_kwargs["dcp_kv_cache_interleave_size"], + ) + parallel_group.add_argument( + "--cp-kv-cache-interleave-size", + **{ + **parallel_kwargs["cp_kv_cache_interleave_size"], + "default": None, + }, + ) + parallel_group.add_argument( + "--prefill-context-parallel-size", + "-pcp", + **parallel_kwargs["prefill_context_parallel_size"], + ) + parallel_group.add_argument( + "--data-parallel-size", "-dp", **parallel_kwargs["data_parallel_size"] + ) + parallel_group.add_argument( + "--data-parallel-rank", + "-dpn", + type=int, + help="Data parallel rank of this instance. " + "When set, enables external load balancer mode for MoE " + "data-parallel deployments. Unsupported for non-MoE models; " + "launch independent vLLM instances instead.", + ) + parallel_group.add_argument( + "--data-parallel-start-rank", + "-dpr", + type=int, + help="Starting data parallel rank for secondary nodes.", + ) + parallel_group.add_argument( + "--data-parallel-size-local", + "-dpl", + type=int, + help="Number of data parallel replicas to run on this node.", + ) + parallel_group.add_argument( + "--data-parallel-address", + "-dpa", + type=str, + help="Address of data parallel cluster head-node.", + ) + parallel_group.add_argument( + "--data-parallel-rpc-port", + "-dpp", + type=int, + help="Fixed port for data parallel RPC communication. All nodes " + "must use the same port.", + ) + parallel_group.add_argument( + "--data-parallel-backend", + "-dpb", + type=str, + default="mp", + help='Backend for data parallel, either "mp" or "ray".', + ) + parallel_group.add_argument( + "--data-parallel-hybrid-lb", + "-dph", + **parallel_kwargs["data_parallel_hybrid_lb"], + ) + parallel_group.add_argument( + "--data-parallel-external-lb", + "-dpe", + **parallel_kwargs["data_parallel_external_lb"], + ) + parallel_group.add_argument( + "--data-parallel-multi-port-external-lb", + "-dpm", + action="store_true", + default=False, + help="Run a node-local supervisor that launches one external-LB API " + "server per local data parallel rank and exposes aggregated health on " + "a supervisor port.", + ) + parallel_group.add_argument("--enable-hc-sp", **parallel_kwargs["enable_hc_sp"]) + parallel_group.add_argument( + "--enable-expert-parallel", + "-ep", + **parallel_kwargs["enable_expert_parallel"], + ) + parallel_group.add_argument( + "--enable-batch-sharded-sampling", + **parallel_kwargs["enable_batch_sharded_sampling"], + ) + parallel_group.add_argument( + "--enable-ep-weight-filter", + **parallel_kwargs["enable_ep_weight_filter"], + ) + parallel_group.add_argument( + "--all2all-backend", **parallel_kwargs["all2all_backend"] + ) + parallel_group.add_argument("--enable-dbo", **parallel_kwargs["enable_dbo"]) + parallel_group.add_argument( + "--enable-shm-tensor-arena", + **parallel_kwargs["enable_shm_tensor_arena"], + ) + parallel_group.add_argument( + "--ubatch-size", + **parallel_kwargs["ubatch_size"], + ) + parallel_group.add_argument( + "--enable-elastic-ep", **parallel_kwargs["enable_elastic_ep"] + ) + parallel_group.add_argument( + "--elastic-ep-max-dp-size", + **parallel_kwargs["elastic_ep_max_dp_size"], + ) + parallel_group.add_argument( + "--dbo-decode-token-threshold", + **parallel_kwargs["dbo_decode_token_threshold"], + ) + parallel_group.add_argument( + "--dp-sync-interval", + **parallel_kwargs["dp_sync_interval"], + ) + parallel_group.add_argument( + "--dbo-prefill-token-threshold", + **parallel_kwargs["dbo_prefill_token_threshold"], + ) + parallel_group.add_argument( + "--disable-nccl-for-dp-synchronization", + **parallel_kwargs["disable_nccl_for_dp_synchronization"], + ) + parallel_group.add_argument("--enable-eplb", **parallel_kwargs["enable_eplb"]) + parallel_group.add_argument("--eplb-config", **parallel_kwargs["eplb_config"]) + parallel_group.add_argument( + "--expert-placement-strategy", + **parallel_kwargs["expert_placement_strategy"], + ) + + parallel_group.add_argument( + "--max-parallel-loading-workers", + **parallel_kwargs["max_parallel_loading_workers"], + ) + parallel_group.add_argument( + "--ray-workers-use-nsight", **parallel_kwargs["ray_workers_use_nsight"] + ) + parallel_group.add_argument( + "--disable-custom-all-reduce", + **parallel_kwargs["disable_custom_all_reduce"], + ) + parallel_group.add_argument("--worker-cls", **parallel_kwargs["worker_cls"]) + parallel_group.add_argument( + "--worker-extension-cls", **parallel_kwargs["worker_extension_cls"] + ) + parallel_group.add_argument( + "--enable-fault-tolerance", **parallel_kwargs["enable_fault_tolerance"] + ) + parallel_group.add_argument( + "--fault-tolerance-config", **parallel_kwargs["fault_tolerance_config"] + ) + + # KV cache arguments + cache_kwargs = get_kwargs(CacheConfig) + cache_group = parser.add_argument_group( + title="CacheConfig", + description=CacheConfig.__doc__, + ) + cache_group.add_argument("--block-size", **cache_kwargs["block_size"]) + cache_group.add_argument( + "--gpu-memory-utilization", + "--device-memory-utilization", + dest="gpu_memory_utilization", + **cache_kwargs["gpu_memory_utilization"], + ) + cache_group.add_argument( + "--kv-cache-memory-bytes", **cache_kwargs["kv_cache_memory_bytes"] + ) + cache_group.add_argument("--kv-cache-dtype", **cache_kwargs["cache_dtype"]) + cache_group.add_argument( + "--num-gpu-blocks-override", **cache_kwargs["num_gpu_blocks_override"] + ) + cache_group.add_argument( + "--enable-prefix-caching", + **{ + **cache_kwargs["enable_prefix_caching"], + "default": None, + }, + ) + cache_group.add_argument( + "--prefix-caching-hash-algo", **cache_kwargs["prefix_caching_hash_algo"] + ) + cache_group.add_argument( + "--prefix-cache-retention-interval", + **cache_kwargs["prefix_cache_retention_interval"], + ) + cache_group.add_argument( + "--kv-cache-dtype-skip-layers", **cache_kwargs["kv_cache_dtype_skip_layers"] + ) + cache_group.add_argument( + "--kv-sharing-fast-prefill", **cache_kwargs["kv_sharing_fast_prefill"] + ) + cache_group.add_argument( + "--swa-bounded-replay", **cache_kwargs["swa_bounded_replay"] + ) + cache_group.add_argument( + "--mamba-cache-dtype", **cache_kwargs["mamba_cache_dtype"] + ) + cache_group.add_argument( + "--mamba-ssm-cache-dtype", **cache_kwargs["mamba_ssm_cache_dtype"] + ) + cache_group.add_argument( + "--mamba-block-size", **cache_kwargs["mamba_block_size"] + ) + cache_group.add_argument( + "--prefix-match-unit", **cache_kwargs["prefix_match_unit"] + ) + cache_group.add_argument( + "--mamba-cache-mode", **cache_kwargs["mamba_cache_mode"] + ) + cache_group.add_argument( + "--enable-mamba-shared-prefix-checkpoint", + **cache_kwargs["enable_mamba_shared_prefix_checkpoint"], + ) + cache_group.add_argument( + "--replayssm-buffer-len", **cache_kwargs["replayssm_buffer_len"] + ) + cache_group.add_argument("--use-replayssm", **cache_kwargs["use_replayssm"]) + cache_group.add_argument( + "--kv-offloading-size", **cache_kwargs["kv_offloading_size"] + ) + cache_group.add_argument( + "--kv-offloading-backend", **cache_kwargs["kv_offloading_backend"] + ) + + # Model weight offload related configs + offload_kwargs = get_kwargs(OffloadConfig) + uva_kwargs = get_kwargs(UVAOffloadConfig) + prefetch_kwargs = get_kwargs(PrefetchOffloadConfig) + offload_group = parser.add_argument_group( + title="OffloadConfig", + description=OffloadConfig.__doc__, + ) + offload_group.add_argument( + "--offload-backend", **offload_kwargs["offload_backend"] + ) + offload_group.add_argument("--cpu-offload-gb", **uva_kwargs["cpu_offload_gb"]) + offload_group.add_argument( + "--cpu-offload-params", **uva_kwargs["cpu_offload_params"] + ) + offload_group.add_argument( + "--offload-group-size", + **prefetch_kwargs["offload_group_size"], + ) + offload_group.add_argument( + "--offload-num-in-group", + **prefetch_kwargs["offload_num_in_group"], + ) + offload_group.add_argument( + "--offload-prefetch-step", + **prefetch_kwargs["offload_prefetch_step"], + ) + offload_group.add_argument( + "--offload-params", **prefetch_kwargs["offload_params"] + ) + + # Multimodal related configs + multimodal_kwargs = get_kwargs(MultiModalConfig) + multimodal_group = parser.add_argument_group( + title="MultiModalConfig", + description=MultiModalConfig.__doc__, + ) + multimodal_group.add_argument( + "--language-model-only", **multimodal_kwargs["language_model_only"] + ) + multimodal_group.add_argument( + "--limit-mm-per-prompt", **multimodal_kwargs["limit_per_prompt"] + ) + multimodal_group.add_argument( + "--enable-mm-embeds", **multimodal_kwargs["enable_mm_embeds"] + ) + multimodal_group.add_argument( + "--media-io-kwargs", **multimodal_kwargs["media_io_kwargs"] + ) + multimodal_group.add_argument( + "--mm-processor-kwargs", **multimodal_kwargs["mm_processor_kwargs"] + ) + multimodal_group.add_argument( + "--mm-processor-cache-gb", **multimodal_kwargs["mm_processor_cache_gb"] + ) + multimodal_group.add_argument( + "--mm-processor-cache-type", **multimodal_kwargs["mm_processor_cache_type"] + ) + multimodal_group.add_argument( + "--mm-hasher-algorithm", **multimodal_kwargs["mm_hasher_algorithm"] + ) + multimodal_group.add_argument( + "--mm-shm-cache-max-object-size-mb", + **multimodal_kwargs["mm_shm_cache_max_object_size_mb"], + ) + multimodal_group.add_argument( + "--mm-encoder-only", **multimodal_kwargs["mm_encoder_only"] + ) + multimodal_group.add_argument( + "--mm-encoder-tp-mode", **multimodal_kwargs["mm_encoder_tp_mode"] + ) + multimodal_group.add_argument( + "--mm-encoder-attn-backend", + **multimodal_kwargs["mm_encoder_attn_backend"], + ) + multimodal_group.add_argument( + "--mm-encoder-attn-dtype", + **multimodal_kwargs["mm_encoder_attn_dtype"], + ) + multimodal_group.add_argument( + "--mm-encoder-fp8-scale-path", + **multimodal_kwargs["mm_encoder_fp8_scale_path"], + ) + multimodal_group.add_argument( + "--mm-encoder-fp8-scale-save-path", + **multimodal_kwargs["mm_encoder_fp8_scale_save_path"], + ) + multimodal_group.add_argument( + "--mm-encoder-fp8-scale-save-margin", + **multimodal_kwargs["mm_encoder_fp8_scale_save_margin"], + ) + multimodal_group.add_argument( + "--interleave-mm-strings", **multimodal_kwargs["interleave_mm_strings"] + ) + multimodal_group.add_argument( + "--skip-mm-profiling", **multimodal_kwargs["skip_mm_profiling"] + ) + + multimodal_group.add_argument( + "--video-pruning-rate", **multimodal_kwargs["video_pruning_rate"] + ) + multimodal_group.add_argument( + "--video-pruning-method", + **multimodal_kwargs["video_pruning_method"], + ) + multimodal_group.add_argument( + "--mm-tensor-ipc", **multimodal_kwargs["mm_tensor_ipc"] + ) + multimodal_group.add_argument( + "--mm-processor-device", + choices=["auto", "cpu"] + + ( + [current_platform.device_type] + if current_platform.device_type not in ("", "cpu") + else [] + ), + default="auto", + help="Device the HF multi-modal processor runs the image/video " + "transform on. Convenience for `--mm-processor-kwargs " + "'{\"device\": ...}'`: the value is resolved here and stored there, " + "it is not kept as separate state. Only takes effect for HF " + '"fast" (torchvision-backed) processors, which accept a `device` ' + "argument; the others ignore it and stay on CPU.\n\n" + '"auto" uses the accelerator on encoder instances of an ' + "encode/prefill/decode deployment -- an EC producer that is not " + "also a consumer allocates no KV cache, so its accelerator is not " + "contended by the language model -- and then only when " + "`--mm-tensor-ipc=torch_shm` can carry device tensors, since every " + "other transport would copy the result back to the host and that " + 'copy costs more than it saves. "auto" resolves to "cpu" ' + "everywhere else.", + ) + multimodal_group.add_argument( + "--mm-ipc-gpu-memory-gb", + **multimodal_kwargs["mm_ipc_gpu_memory_gb"], + ) + multimodal_group.add_argument( + "--mm-device-do-normalize", + **{ + **multimodal_kwargs["mm_device_do_normalize"], + "default": None, + }, + ) + + # LoRA related configs + lora_kwargs = get_kwargs(LoRAConfig) + lora_group = parser.add_argument_group( + title="LoRAConfig", + description=LoRAConfig.__doc__, + ) + lora_group.add_argument( + "--enable-lora", + action=argparse.BooleanOptionalAction, + help="If True, enable handling of LoRA adapters.", + ) + lora_group.add_argument("--max-loras", **lora_kwargs["max_loras"]) + lora_group.add_argument("--max-lora-rank", **lora_kwargs["max_lora_rank"]) + lora_group.add_argument( + "--lora-dtype", + **lora_kwargs["lora_dtype"], + ) + lora_group.add_argument( + "--enable-tower-connector-lora", + **lora_kwargs["enable_tower_connector_lora"], + ) + lora_group.add_argument("--max-cpu-loras", **lora_kwargs["max_cpu_loras"]) + lora_group.add_argument( + "--fully-sharded-loras", **lora_kwargs["fully_sharded_loras"] + ) + lora_group.add_argument( + "--lora-target-modules", **lora_kwargs["target_modules"] + ) + lora_group.add_argument("--default-mm-loras", **lora_kwargs["default_mm_loras"]) + lora_group.add_argument( + "--specialize-active-lora", **lora_kwargs["specialize_active_lora"] + ) + lora_group.add_argument( + "--enable-mixed-moe-lora-format", + **lora_kwargs["enable_mixed_moe_lora_format"], + ) + lora_group.add_argument( + "--enable-moe-shared-loras", + **lora_kwargs["enable_moe_shared_loras"], + ) + lora_group.add_argument( + "--max-lora-cls-labels", + **lora_kwargs["max_lora_cls_labels"], + ) + + # Logging arguments + logging_group = parser.add_argument_group( + title="LoggingConfig", + description=LoggingConfig.__doc__, + ) + logging_config_kwargs = get_kwargs(VllmConfig)["logging_config"] + logging_config_kwargs["default"] = argparse.SUPPRESS + logging_group.add_argument("--logging-config", **logging_config_kwargs) + logging_group.add_argument( + "--log-level", + choices=get_args(LogLevel), + default=argparse.SUPPRESS, + help=( + "Shortcut for --logging-config.log_level. " + "Overrides that field if both are specified." + ), + ) + logging_group.add_argument( + "--log-config-file", + dest="log_config_file", + default=argparse.SUPPRESS, + metavar="PYLOGGING_CONFIG_FILE", + action=DeprecatedLogConfigFileAction, + help=_LOG_CONFIG_FILE_DEPRECATION_MESSAGE, + ) + # Retain the warning in v0.31.0 and v0.32.0. Remove this option and its + # compatibility mapping in create_logging_config() in v0.33.0. + + # Observability arguments + observability_kwargs = get_kwargs(ObservabilityConfig) + observability_group = parser.add_argument_group( + title="ObservabilityConfig", + description=ObservabilityConfig.__doc__, + ) + observability_group.add_argument( + "--show-hidden-metrics-for-version", + **observability_kwargs["show_hidden_metrics_for_version"], + ) + observability_group.add_argument( + "--otlp-traces-endpoint", **observability_kwargs["otlp_traces_endpoint"] + ) + observability_group.add_argument( + "--collect-detailed-traces", + **observability_kwargs["collect_detailed_traces"], + ) + observability_group.add_argument( + "--per-request-spec-decode-metrics", + **observability_kwargs["per_request_spec_decode_metrics"], + ) + observability_group.add_argument( + "--kv-cache-metrics", **observability_kwargs["kv_cache_metrics"] + ) + observability_group.add_argument( + "--kv-cache-metrics-sample", + **observability_kwargs["kv_cache_metrics_sample"], + ) + observability_group.add_argument( + "--custom-histogram-buckets", + **observability_kwargs["custom_histogram_buckets"], + ) + observability_group.add_argument( + "--cudagraph-metrics", + **observability_kwargs["cudagraph_metrics"], + ) + observability_group.add_argument( + "--enable-layerwise-nvtx-tracing", + **observability_kwargs["enable_layerwise_nvtx_tracing"], + ) + observability_group.add_argument( + "--enable-mfu-metrics", + **observability_kwargs["enable_mfu_metrics"], + ) + observability_group.add_argument( + "--enable-logging-iteration-details", + **observability_kwargs["enable_logging_iteration_details"], + ) + observability_group.add_argument( + "--jit-monitor-mode", + **observability_kwargs["jit_monitor_mode"], + ) + observability_group.add_argument( + "--jit-monitor-verbose", + **observability_kwargs["jit_monitor_verbose"], + ) + + # Scheduler arguments + scheduler_kwargs = get_kwargs(SchedulerConfig) + scheduler_group = parser.add_argument_group( + title="SchedulerConfig", + description=SchedulerConfig.__doc__, + ) + scheduler_group.add_argument( + "--max-num-batched-tokens", + **{ + **scheduler_kwargs["max_num_batched_tokens"], + "default": None, + }, + ) + scheduler_group.add_argument( + "--max-num-scheduled-tokens", + **{ + **scheduler_kwargs["max_num_scheduled_tokens"], + "default": None, + }, + ) + scheduler_group.add_argument( + "--max-num-seqs", + **{ + **scheduler_kwargs["max_num_seqs"], + "default": None, + }, + ) + scheduler_group.add_argument( + "--max-num-active-seqs", + **scheduler_kwargs["max_num_active_seqs"], + ) + scheduler_group.add_argument( + "--max-num-queued-reqs", **scheduler_kwargs["max_num_queued_reqs"] + ) + scheduler_group.add_argument( + "--max-num-queued-tokens", + **scheduler_kwargs["max_num_queued_tokens"], + ) + scheduler_group.add_argument( + "--long-prefill-token-threshold", + **scheduler_kwargs["long_prefill_token_threshold"], + ) + scheduler_group.add_argument( + "--long-prefill-token-threshold-adaptive", + **scheduler_kwargs["long_prefill_token_threshold_adaptive"], + ) + # multi-step scheduling has been removed; corresponding arguments + # are no longer supported. + scheduler_group.add_argument( + "--scheduling-policy", **scheduler_kwargs["policy"] + ) + scheduler_group.add_argument( + "--enable-chunked-prefill", + **{ + **scheduler_kwargs["enable_chunked_prefill"], + "default": None, + }, + ) + scheduler_group.add_argument( + "--disable-chunked-mm-input", **scheduler_kwargs["disable_chunked_mm_input"] + ) + scheduler_group.add_argument( + "--scheduler-cls", **scheduler_kwargs["scheduler_cls"] + ) + scheduler_group.add_argument( + "--scheduler-reserve-full-isl", + **scheduler_kwargs["scheduler_reserve_full_isl"], + ) + scheduler_group.add_argument("--watermark", **scheduler_kwargs["watermark"]) + scheduler_group.add_argument( + "--prefill-schedule-interval", + **scheduler_kwargs["prefill_schedule_interval"], + ) + scheduler_group.add_argument( + "--disable-hybrid-kv-cache-manager", + **scheduler_kwargs["disable_hybrid_kv_cache_manager"], + ) + scheduler_group.add_argument( + "--async-scheduling", **scheduler_kwargs["async_scheduling"] + ) + scheduler_group.add_argument( + "--stream-interval", **scheduler_kwargs["stream_interval"] + ) + + # Compilation arguments + compilation_kwargs = get_kwargs(CompilationConfig) + compilation_group = parser.add_argument_group( + title="CompilationConfig", + description=CompilationConfig.__doc__, + ) + compilation_group.add_argument( + "--cudagraph-capture-sizes", **compilation_kwargs["cudagraph_capture_sizes"] + ) + compilation_group.add_argument( + "--max-cudagraph-capture-size", + **compilation_kwargs["max_cudagraph_capture_size"], + ) + + # Kernel arguments + kernel_kwargs = get_kwargs(KernelConfig) + kernel_group = parser.add_argument_group( + title="KernelConfig", + description=KernelConfig.__doc__, + ) + kernel_group.add_argument("--ir-op-priority", **kernel_kwargs["ir_op_priority"]) + kernel_group.add_argument( + "--enable-flashinfer-autotune", + **kernel_kwargs["enable_flashinfer_autotune"], + ) + moe_backend_kwargs = kernel_kwargs["moe_backend"] + moe_backend_kwargs["type"] = lambda s: s.lower().replace("-", "_") + kernel_group.add_argument("--moe-backend", **moe_backend_kwargs) + linear_backend_kwargs = kernel_kwargs["linear_backend"] + linear_backend_kwargs["type"] = lambda s: s.lower().replace("-", "_") + kernel_group.add_argument("--linear-backend", **linear_backend_kwargs) + sparse_indexer_topk_kwargs = kernel_kwargs["sparse_indexer_topk_backend"] + sparse_indexer_topk_kwargs["type"] = lambda s: s.lower().replace("-", "_") + kernel_group.add_argument( + "--sparse-indexer-topk-backend", **sparse_indexer_topk_kwargs + ) + + # vLLM arguments + vllm_kwargs = get_kwargs(VllmConfig) + vllm_group = parser.add_argument_group( + title="VllmConfig", + description=VllmConfig.__doc__, + ) + # We construct SpeculativeConfig using fields from other configs in + # create_engine_config. So we set the type to a JSON string here to + # delay the Pydantic validation that comes with SpeculativeConfig. + vllm_kwargs["speculative_config"]["type"] = optional_type(json.loads) + vllm_group.add_argument( + "--speculative-config", "-sc", **vllm_kwargs["speculative_config"] + ) + speculative_kwargs = get_kwargs(SpeculativeConfig) + vllm_group.add_argument("--spec-method", **speculative_kwargs["method"]) + vllm_group.add_argument("--spec-model", **speculative_kwargs["model"]) + vllm_group.add_argument( + "--spec-tokens", **speculative_kwargs["num_speculative_tokens"] + ) + vllm_kwargs["watermark_config"]["type"] = optional_type(json.loads) + vllm_group.add_argument("--watermark-config", **vllm_kwargs["watermark_config"]) + vllm_kwargs["diffusion_config"]["type"] = optional_type(json.loads) + vllm_group.add_argument( + "--diffusion-config", "-dc", **vllm_kwargs["diffusion_config"] + ) + vllm_group.add_argument( + "--kv-transfer-config", **vllm_kwargs["kv_transfer_config"] + ) + vllm_group.add_argument("--kv-events-config", **vllm_kwargs["kv_events_config"]) + vllm_group.add_argument( + "--ec-transfer-config", **vllm_kwargs["ec_transfer_config"] + ) + vllm_group.add_argument( + "--ec-manager-config", **vllm_kwargs["ec_manager_config"] + ) + vllm_group.add_argument( + "--compilation-config", "-cc", **vllm_kwargs["compilation_config"] + ) + vllm_group.add_argument( + "--attention-config", "-ac", **vllm_kwargs["attention_config"] + ) + vllm_group.add_argument("--engram-config", **vllm_kwargs["engram_config"]) + vllm_group.add_argument("--reasoning-config", **vllm_kwargs["reasoning_config"]) + vllm_group.add_argument("--kernel-config", **vllm_kwargs["kernel_config"]) + vllm_group.add_argument( + "--additional-config", **vllm_kwargs["additional_config"] + ) + vllm_group.add_argument( + "--structured-outputs-config", **vllm_kwargs["structured_outputs_config"] + ) + vllm_group.add_argument( + "--aux-output-config", **vllm_kwargs["aux_output_config"] + ) + vllm_group.add_argument("--profiler-config", **vllm_kwargs["profiler_config"]) + vllm_group.add_argument( + "--optimization-level", **vllm_kwargs["optimization_level"] + ) + vllm_group.add_argument("--performance-mode", **vllm_kwargs["performance_mode"]) + vllm_group.add_argument( + "--weight-transfer-config", **vllm_kwargs["weight_transfer_config"] + ) + + # Other arguments + parser.add_argument( + "--disable-log-stats", + action="store_true", + help="Disable logging statistics.", + ) + + parser.add_argument( + "--aggregate-engine-logging", + action="store_true", + help="Log aggregate rather than per-engine statistics " + "when using data parallelism.", + ) + + parser.add_argument( + "--fail-on-environ-validation", + help="If set, the engine will raise an error if " + "environment validation fails.", + default=False, + action=argparse.BooleanOptionalAction, + ) + + parser.add_argument( + "--shutdown-timeout", + type=int, + default=0, + help="Shutdown timeout in seconds. 0 = abort, >0 = wait.", + ) + + parser.add_argument( + "--gdn-prefill-backend", + dest="gdn_prefill_backend", + choices=["flashinfer", "triton", "cutedsl", "aiter_flydsl"], + default=None, + help="Select GDN prefill backend.", + ) + parser.add_argument( + "--kda-prefill-backend", + dest="kda_prefill_backend", + choices=["auto", "triton", "flashkda", "flashinfer", "fused"], + default=None, + help="Select KDA prefill backend. 'flashkda' is CUDA-only and " + "'fused' is ROCm-only; 'auto' picks a supported backend.", + ) + parser.add_argument( + "--kda-decode-backend", + dest="kda_decode_backend", + choices=["auto", "native", "flashinfer", "triton"], + default=None, + help="Select KDA decode backend.", + ) + return parser + + @classmethod + def from_cli_args(cls, args: argparse.Namespace): + # Get the list of attributes of this dataclass. + attrs = [attr.name for attr in dataclasses.fields(cls)] + + # Set the attributes from the parsed arguments. + engine_args = cls( + **{attr: getattr(args, attr) for attr in attrs if hasattr(args, attr)} + ) + return engine_args + + def create_model_config(self) -> ModelConfig: + if not envs.VLLM_ENABLE_V1_MULTIPROCESSING: + logger.warning( + "The global random seed is set to %d. Since " + "VLLM_ENABLE_V1_MULTIPROCESSING is set to False, this may " + "affect the random state of the Python process that " + "launched vLLM.", + self.seed, + ) + if self.disable_cascade_attn is not None: + warnings.warn(_DISABLE_CASCADE_ATTN_DEPRECATION_MESSAGE, stacklevel=1) + + return ModelConfig( + model=self.model, + model_weights=self.model_weights, + hf_config_path=self.hf_config_path, + runner=self.runner, + convert=self.convert, + tokenizer=self.tokenizer, # type: ignore[arg-type] + tokenizer_mode=self.tokenizer_mode, + trust_remote_code=self.trust_remote_code, + allowed_local_media_path=self.allowed_local_media_path, + allowed_media_domains=self.allowed_media_domains, + dtype=self.dtype, + seed=self.seed, + revision=self.revision, + code_revision=self.code_revision, + hf_token=self.hf_token, + hf_overrides=self.hf_overrides, + model_class_overrides=self.model_class_overrides, + tokenizer_revision=self.tokenizer_revision, + max_model_len=self.max_model_len, + quantization=self.quantization, + quantization_config=self.quantization_config, + allow_deprecated_quantization=self.allow_deprecated_quantization, + enforce_eager=self.enforce_eager, + return_sampling_mask=self.return_sampling_mask, + max_logprobs=self.max_logprobs, + logprobs_mode=self.logprobs_mode, + use_fp64_gumbel=self.use_fp64_gumbel, + enable_trace_replay=self.enable_trace_replay, + disable_sliding_window=self.disable_sliding_window, + skip_tokenizer_init=self.skip_tokenizer_init, + enable_prompt_embeds=self.enable_prompt_embeds, + served_model_name=self.served_model_name, + language_model_only=self.language_model_only, + limit_mm_per_prompt=self.limit_mm_per_prompt, + enable_mm_embeds=self.enable_mm_embeds, + interleave_mm_strings=self.interleave_mm_strings, + media_io_kwargs=self.media_io_kwargs, + skip_mm_profiling=self.skip_mm_profiling, + config_format=self.config_format, + mm_processor_kwargs=self.mm_processor_kwargs, + mm_processor_cache_gb=self.mm_processor_cache_gb, + mm_processor_cache_type=self.mm_processor_cache_type, + mm_shm_cache_max_object_size_mb=self.mm_shm_cache_max_object_size_mb, + mm_hasher_algorithm=self.mm_hasher_algorithm, + mm_encoder_only=self.mm_encoder_only, + mm_encoder_tp_mode=self.mm_encoder_tp_mode, + mm_encoder_attn_backend=self.mm_encoder_attn_backend, + mm_encoder_attn_dtype=self.mm_encoder_attn_dtype, + mm_encoder_fp8_scale_path=self.mm_encoder_fp8_scale_path, + mm_encoder_fp8_scale_save_path=self.mm_encoder_fp8_scale_save_path, + mm_encoder_fp8_scale_save_margin=self.mm_encoder_fp8_scale_save_margin, + pooler_config=self.pooler_config, + generation_config=self.generation_config, + override_generation_config=self.override_generation_config, + enable_sleep_mode=self.enable_sleep_mode, + sleep_mode_offload_cudagraph=self.sleep_mode_offload_cudagraph, + sleep_preserve_parameter_names=self.sleep_preserve_parameter_names, + enable_cumem_allocator=self.enable_cumem_allocator, + enable_nccl_comm_suspend=self.enable_nccl_comm_suspend, + model_impl=self.model_impl, + logits_processors=self.logits_processors, + video_pruning_rate=self.video_pruning_rate, + video_pruning_method=self.video_pruning_method, + mm_tensor_ipc=self.mm_tensor_ipc, + mm_ipc_gpu_memory_gb=self.mm_ipc_gpu_memory_gb, + mm_device_do_normalize=self.mm_device_do_normalize, + mm_processor_device=self.mm_processor_device, + io_processor_plugin=self.io_processor_plugin, + renderer_num_workers=self.renderer_num_workers, + ) + + def validate_tensorizer_args(self): + from vllm.model_executor.model_loader.tensorizer import TensorizerConfig + + for key in self.model_loader_extra_config: + if key in TensorizerConfig._fields: + self.model_loader_extra_config["tensorizer_config"][key] = ( + self.model_loader_extra_config[key] + ) + + def create_load_config(self) -> LoadConfig: + if self.load_format == "tensorizer": + if hasattr(self.model_loader_extra_config, "to_serializable"): + self.model_loader_extra_config = ( + self.model_loader_extra_config.to_serializable() + ) + self.model_loader_extra_config["tensorizer_config"] = {} + self.model_loader_extra_config["tensorizer_config"]["tensorizer_dir"] = ( + self.model + ) + self.validate_tensorizer_args() + + return LoadConfig( + load_format=self.load_format, + download_dir=self.download_dir, + safetensors_load_strategy=self.safetensors_load_strategy, + safetensors_prefetch_num_threads=self.safetensors_prefetch_num_threads, + safetensors_prefetch_block_size=self.safetensors_prefetch_block_size, + model_loader_extra_config=self.model_loader_extra_config, + ignore_patterns=self.ignore_patterns, + use_tqdm_on_load=self.use_tqdm_on_load, + pt_load_map_location=self.pt_load_map_location, + ) + + def create_speculative_config( + self, + target_model_config: ModelConfig, + target_parallel_config: ParallelConfig, + ) -> SpeculativeConfig | None: + """Initializes and returns a SpeculativeConfig object based on + `speculative_config`. + """ + for flag, key, value in ( + ("--spec-method", "method", self.spec_method), + ("--spec-model", "model", self.spec_model), + ("--spec-tokens", "num_speculative_tokens", self.spec_tokens), + ): + if value is None: + continue + if self.speculative_config is None: + self.speculative_config = {} + if key in self.speculative_config: + raise ValueError( + f"{flag} and --speculative-config['{key}'] are mutually exclusive" + ) + self.speculative_config[key] = value + + if self.speculative_config is None: + return None + + self.speculative_config = { + k.replace("-", "_"): v for k, v in self.speculative_config.items() + } + + # Note(Shangming): These parameters are not obtained from the cli arg + # '--speculative-config' and must be passed in when creating the engine + # config. + self.speculative_config.update( + { + "target_model_config": target_model_config, + "target_parallel_config": target_parallel_config, + } + ) + return SpeculativeConfig(**self.speculative_config) + + def _resolve_device_ids(self) -> list[int] | None: + if not self.device_ids: + return None + if self.distributed_executor_backend == "ray": + logger.warning( + "--device-ids has no effect when using the Ray executor. " + "Use Ray placement groups for GPU selection instead." + ) + ids = self.device_ids + if len(set(ids)) != len(ids): + raise ValueError(f"--device-ids must not contain duplicates: {ids}") + if all(isinstance(i, str) for i in ids): + return [ + current_platform.device_control_id_to_physical_device_id(i) + for i in cast(list[str], ids) + ] + if any(isinstance(i, str) for i in ids): + raise ValueError("--device-ids must not mix integer IDs and UUIDs") + int_ids = cast(list[int], ids) + # Compose with CUDA_VISIBLE_DEVICES: if CVD is set, treat + # --device-ids values as indices into the CVD-visible set. + cvd = getattr( + envs, + current_platform.device_control_env_var, + os.environ.get(current_platform.device_control_env_var), + ) + if cvd: + cvd_ids = [ + current_platform.device_control_id_to_physical_device_id(x) + for x in cvd.split(",") + ] + for i in int_ids: + if not 0 <= i < len(cvd_ids): + raise ValueError( + f"--device-ids index {i} is out of range for " + f"{current_platform.device_control_env_var}" + f"={cvd} ({len(cvd_ids)} devices visible)" + ) + return [cvd_ids[i] for i in int_ids] + return int_ids + + def create_diffusion_config(self) -> DiffusionConfig | None: + if self.diffusion_config is None: + return None + cfg = self.diffusion_config + if isinstance(cfg, str): + cfg = json.loads(cfg) + return DiffusionConfig(**cfg) + + def create_watermark_config(self) -> WatermarkConfig | None: + if self.watermark_config is None: + return None + cfg = self.watermark_config + if isinstance(cfg, str): + cfg = json.loads(cfg) + return WatermarkConfig(**cfg) + + def create_structured_outputs_config(self) -> StructuredOutputsConfig: + """Merge frontend parser flags into the structured outputs config. + + Mutates `self.structured_outputs_config` in place and returns it + (not a copy). Model-specific defaults (e.g. gpt_oss -> + "openai_gptoss") are applied later by `verify_and_update_config` + only when the resolved value is still empty, so explicit CLI flags + take precedence. + """ + if self.reasoning_parser: + self.structured_outputs_config.reasoning_parser = self.reasoning_parser + + if self.reasoning_parser_plugin: + self.structured_outputs_config.reasoning_parser_plugin = ( + self.reasoning_parser_plugin + ) + + return self.structured_outputs_config + + def create_observability_config(self) -> ObservabilityConfig: + return ObservabilityConfig( + show_hidden_metrics_for_version=self.show_hidden_metrics_for_version, + otlp_traces_endpoint=self.otlp_traces_endpoint, + collect_detailed_traces=self.collect_detailed_traces, + per_request_spec_decode_metrics=self.per_request_spec_decode_metrics, + kv_cache_metrics=self.kv_cache_metrics, + kv_cache_metrics_sample=self.kv_cache_metrics_sample, + custom_histogram_buckets=self.custom_histogram_buckets, + cudagraph_metrics=self.cudagraph_metrics, + enable_layerwise_nvtx_tracing=self.enable_layerwise_nvtx_tracing, + enable_mfu_metrics=self.enable_mfu_metrics, + enable_mm_processor_stats=self.enable_mm_processor_stats, + enable_logging_iteration_details=self.enable_logging_iteration_details, + jit_monitor_mode=self.jit_monitor_mode, + jit_monitor_verbose=self.jit_monitor_verbose, + ) + + def create_logging_config(self) -> LoggingConfig: + config = self.logging_config or LoggingConfig() + if self.log_level is not None: + config = dataclasses.replace(config, log_level=self.log_level) + if self.log_config_file is not None: + config = dataclasses.replace( + config, pylogging_config_file=self.log_config_file + ) + return config + + def create_engine_config( + self, + usage_context: UsageContext | None = None, + headless: bool = False, + ) -> VllmConfig: + """Create the VllmConfig. + + NOTE: If VllmConfig is incompatible, we raise an error. + """ + logging_config = self.create_logging_config() + configure_logging_if_needed(logging_config) + + current_platform.pre_register_and_update() + + device_config = DeviceConfig(device=cast(Device, current_platform.device_type)) + + current_platform.validate_environ(self.fail_on_environ_validation) + + # Check if the model is a speculator and override model/tokenizer/config + # BEFORE creating ModelConfig, so the config is created with the target model + # Skip speculator detection for cloud storage models (eg: S3, GCS) since + # HuggingFace cannot load configs directly from S3 URLs. S3 models can still + # use speculators with explicit --speculative-config. + if not is_cloud_storage(self.model): + (self.model, self.tokenizer, self.speculative_config) = ( + maybe_override_with_speculators( + model=self.model, + tokenizer=self.tokenizer, + revision=self.revision, + trust_remote_code=self.trust_remote_code, + vllm_speculative_config=self.speculative_config, + hf_token=self.hf_token, + ) + ) + + model_config = self.create_model_config() + self.model = model_config.model + self.model_weights = model_config.model_weights + self.tokenizer = model_config.tokenizer + + self._check_feature_supported() + self._set_default_chunked_prefill_and_prefix_caching_args(model_config) + self._set_default_reasoning_config_args() + sliding_window: int | None = None + layer_types = getattr(model_config.hf_text_config, "layer_types", None) + if layer_types is None or all(lt == "sliding_attention" for lt in layer_types): + # Only set CacheConfig.sliding_window if the model is all sliding + # window. Otherwise CacheConfig.sliding_window will override the + # global layers in interleaved sliding window models. + sliding_window = model_config.get_sliding_window() + + # Resolve "auto" kv_cache_dtype to actual value from model config + resolved_cache_dtype = resolve_kv_cache_dtype_string( + self.kv_cache_dtype, model_config + ) + + assert self.enable_prefix_caching is not None, ( + "enable_prefix_caching must be set by this point" + ) + + cache_config = CacheConfig( + block_size=self.block_size, # type: ignore[arg-type] + gpu_memory_utilization=self.gpu_memory_utilization, + kv_cache_memory_bytes=self.kv_cache_memory_bytes, + cache_dtype=resolved_cache_dtype, # type: ignore[arg-type] + is_attention_free=model_config.is_attention_free, + num_gpu_blocks_override=self.num_gpu_blocks_override, + sliding_window=sliding_window, + enable_prefix_caching=self.enable_prefix_caching, + prefix_caching_hash_algo=self.prefix_caching_hash_algo, + prefix_cache_retention_interval=self.prefix_cache_retention_interval, + kv_cache_dtype_skip_layers=self.kv_cache_dtype_skip_layers, + kv_sharing_fast_prefill=self.kv_sharing_fast_prefill, + swa_bounded_replay=self.swa_bounded_replay, + mamba_cache_dtype=self.mamba_cache_dtype, + mamba_ssm_cache_dtype=self.mamba_ssm_cache_dtype, + mamba_block_size=self.mamba_block_size, + prefix_match_unit=self.prefix_match_unit, + mamba_cache_mode=self.mamba_cache_mode, + enable_mamba_shared_prefix_checkpoint=( + self.enable_mamba_shared_prefix_checkpoint + ), + replayssm_buffer_len=self.replayssm_buffer_len, + use_replayssm=self.use_replayssm, + kv_offloading_size=self.kv_offloading_size, + kv_offloading_backend=self.kv_offloading_backend, + ) + + # TurboQuant and UltraQuant both keep boundary attention layers at the + # native dtype, and compute those layers the same way. + uses_packed_kv_backend = ( + resolved_cache_dtype.startswith("turboquant_") + or resolved_cache_dtype == "ultraquant_4bit" + ) + if uses_packed_kv_backend: + from vllm.model_executor.layers.quantization.turboquant.config import ( + TurboQuantConfig, + ) + + boundary = TurboQuantConfig.get_boundary_skip_layers(model_config) + # Sorted so the order does not depend on set iteration: the list is + # a CacheConfig hash factor. Entries can also be attention type + # names (e.g. "sliding_window"), so sort them as strings. + cache_config.kv_cache_dtype_skip_layers = sorted( + set(cache_config.kv_cache_dtype_skip_layers) | set(boundary) + ) + + ray_runtime_env = None + if is_ray_initialized(): + # Ray Serve LLM calls `create_engine_config` in the context + # of a Ray task, therefore we check is_ray_initialized() + # as opposed to is_in_ray_actor(). + import ray + + ray_runtime_env = ray.get_runtime_context().runtime_env + # Avoid logging sensitive environment variables + sanitized_env = ray_runtime_env.to_dict() if ray_runtime_env else {} + if "env_vars" in sanitized_env: + sanitized_env["env_vars"] = { + k: "***" for k in sanitized_env["env_vars"] + } + logger.info("Using ray runtime env (env vars redacted): %s", sanitized_env) + + # Get the current placement group if Ray is initialized and + # we are in a Ray actor. If so, then the placement group will be + # passed to spawned processes. + placement_group = None + if is_in_ray_actor(): + import ray + + # This call initializes Ray automatically if it is not initialized, + # but we should not do this here. + placement_group = ray.util.get_current_placement_group() + + assert not headless or not self.data_parallel_hybrid_lb, ( + "data_parallel_hybrid_lb is not applicable in headless mode" + ) + if self.data_parallel_hybrid_lb and self.data_parallel_external_lb: + raise ValueError( + "Invalid data-parallel launch options: " + "`--data-parallel-hybrid-lb` and " + "`--data-parallel-external-lb` cannot be enabled together. " + "Enable only one load-balancing mode." + ) + if self.nnodes > 1 and self.data_parallel_backend != "mp": + raise ValueError( + "Invalid data-parallel launch options: " + f"`--nnodes {self.nnodes}` requires " + "`--data-parallel-backend mp`; got " + f"`--data-parallel-backend {self.data_parallel_backend}`. " + "Use the MP backend or set `--nnodes 1`." + ) + inferred_data_parallel_rank = 0 + if self.nnodes > 1: + world_size_within_dp = ( + self.pipeline_parallel_size + * self.tensor_parallel_size + * self.prefill_context_parallel_size + ) + world_size = self.data_parallel_size * world_size_within_dp + if world_size % self.nnodes != 0: + raise ValueError( + "Invalid data-parallel launch options: " + f"`--nnodes {self.nnodes}` must evenly divide the total " + f"world size ({world_size}). Adjust `--nnodes`, " + "`--data-parallel-size`, `--pipeline-parallel-size`, " + "`--tensor-parallel-size`, or `--prefill-context-parallel-size`." + ) + if not 0 <= self.node_rank < self.nnodes: + raise ValueError( + "Invalid data-parallel launch options: `--node-rank` must " + f"be between 0 and {self.nnodes - 1}; got " + f"`--node-rank {self.node_rank}`. Set it to this node's " + "zero-based index." + ) + local_world_size = world_size // self.nnodes + inferred_data_parallel_rank = ( + self.node_rank * local_world_size + ) // world_size_within_dp + if self.data_parallel_size > 1 and self.data_parallel_external_lb: + if self.data_parallel_rank is None: + if self.nnodes % self.data_parallel_size != 0: + raise ValueError( + "Invalid data-parallel launch options: " + "`--node-rank` cannot unambiguously identify external " + "data-parallel ranks when `--nnodes` is not divisible " + "by `--data-parallel-size`. Set a unique " + "`--data-parallel-rank` for each external-LB process." + ) + self.data_parallel_rank = inferred_data_parallel_rank + logger.info( + "Inferred data_parallel_rank %d from node_rank %d " + "for external lb", + self.data_parallel_rank, + self.node_rank, + ) + elif self.data_parallel_size_local is None: + # Infer data parallel size local for internal dplb: + self.data_parallel_size_local = max( + local_world_size // world_size_within_dp, 1 + ) + data_parallel_external_lb = ( + self.data_parallel_external_lb or self.data_parallel_rank is not None + ) + if self.enable_fault_tolerance and not data_parallel_external_lb: + raise ValueError( + "Fault tolerance requires external load balancer mode " + "(--data-parallel-external-lb or --data-parallel-rank). " + "Internal LB mode is not supported." + ) + if ( + self.data_parallel_size > 1 + and data_parallel_external_lb + and not model_config.is_moe + ): + raise ValueError( + "Non-MoE models do not support external data parallel mode. " + "For external load balancing, launch independent vLLM " + "instances without --data-parallel-* arguments." + ) + # Local DP rank = 1, use pure-external LB. + if data_parallel_external_lb: + if self.data_parallel_rank is None: + raise ValueError( + "Invalid data-parallel launch options: " + "`--data-parallel-external-lb` requires a data-parallel " + "rank. Set `--data-parallel-rank`, or set " + "`--data-parallel-size` greater than 1 and use `--nnodes` " + "with `--node-rank` so the rank can be inferred." + ) + if self.data_parallel_size_local not in (1, None): + raise ValueError( + "Invalid data-parallel launch options: an external " + "data-parallel rank requires `--data-parallel-size-local " + f"1`; got {self.data_parallel_size_local}. Set it to 1 or " + "omit it." + ) + data_parallel_size_local = 1 + # Use full external lb if we have local_size of 1. + self.data_parallel_hybrid_lb = False + elif self.data_parallel_size_local is not None: + data_parallel_size_local = self.data_parallel_size_local + + if self.data_parallel_start_rank is not None and not headless: + # Infer hybrid LB mode. + self.data_parallel_hybrid_lb = True + + if self.data_parallel_hybrid_lb and data_parallel_size_local == 1: + # Use full external lb if we have local_size of 1. + logger.warning( + "data_parallel_hybrid_lb is not eligible when " + "data_parallel_size_local = 1, autoswitch to " + "data_parallel_external_lb." + ) + data_parallel_external_lb = True + self.data_parallel_hybrid_lb = False + + if data_parallel_size_local == self.data_parallel_size: + # Disable hybrid LB mode if set for a single node + self.data_parallel_hybrid_lb = False + + self.data_parallel_rank = ( + self.data_parallel_start_rank + if self.data_parallel_start_rank is not None + else inferred_data_parallel_rank + ) + if self.nnodes > 1: + logger.info( + "Inferred data_parallel_rank %d from node_rank %d", + self.data_parallel_rank, + self.node_rank, + ) + else: + if self.data_parallel_hybrid_lb: + raise ValueError( + "Invalid data-parallel launch options: " + "`--data-parallel-hybrid-lb` requires " + "`--data-parallel-size-local`. Set it to the number of " + "data-parallel ranks on this node." + ) + + if self.data_parallel_backend == "ray" and ( + envs.VLLM_RAY_DP_PACK_STRATEGY == "span" + ): + # Data parallel size defaults to 1 if DP ranks are spanning + # multiple nodes + data_parallel_size_local = 1 + else: + # Otherwise local DP size defaults to global DP size if not set + data_parallel_size_local = self.data_parallel_size + + # DP address, used in multi-node case for torch distributed group + # and ZMQ sockets. + if self.data_parallel_address is None: + if self.data_parallel_backend == "ray": + host_ip = get_ip() + logger.info( + "Using host IP %s as ray-based data parallel address", host_ip + ) + data_parallel_address = host_ip + else: + assert self.data_parallel_backend == "mp", ( + "data_parallel_backend can only be ray or mp, got %s", + self.data_parallel_backend, + ) + data_parallel_address = ( + self.master_addr or ParallelConfig.data_parallel_master_ip + ) + else: + data_parallel_address = self.data_parallel_address + + # This port is only used when there are remote data parallel engines, + # otherwise the local IPC transport is used. + data_parallel_rpc_port = ( + self.data_parallel_rpc_port + if (self.data_parallel_rpc_port is not None) + else ParallelConfig.data_parallel_rpc_port + ) + + if self.tokens_only and not model_config.skip_tokenizer_init: + model_config.skip_tokenizer_init = True + logger.info("Skipping tokenizer initialization for tokens-only mode.") + + parallel_config = ParallelConfig( + pipeline_parallel_size=self.pipeline_parallel_size, + tensor_parallel_size=self.tensor_parallel_size, + prefill_context_parallel_size=self.prefill_context_parallel_size, + data_parallel_size=self.data_parallel_size, + data_parallel_rank=self.data_parallel_rank or 0, + data_parallel_external_lb=data_parallel_external_lb, + data_parallel_size_local=data_parallel_size_local, + master_addr=self.master_addr, + master_port=self.master_port, + nnodes=self.nnodes, + node_rank=self.node_rank, + distributed_timeout_seconds=self.distributed_timeout_seconds, + cpu_distributed_timeout_seconds=self.cpu_distributed_timeout_seconds, + data_parallel_master_ip=data_parallel_address, + data_parallel_rpc_port=data_parallel_rpc_port, + data_parallel_backend=self.data_parallel_backend, + data_parallel_hybrid_lb=self.data_parallel_hybrid_lb, + is_moe_model=model_config.is_moe, + enable_hc_sp=self.enable_hc_sp, + enable_expert_parallel=self.enable_expert_parallel, + enable_batch_sharded_sampling=self.enable_batch_sharded_sampling, + enable_ep_weight_filter=self.enable_ep_weight_filter, + all2all_backend=self.all2all_backend, + enable_elastic_ep=self.enable_elastic_ep, + elastic_ep_max_dp_size=self.elastic_ep_max_dp_size, + enable_dbo=self.enable_dbo, + enable_shm_tensor_arena=self.enable_shm_tensor_arena, + ubatch_size=self.ubatch_size, + dbo_decode_token_threshold=self.dbo_decode_token_threshold, + dp_sync_interval=self.dp_sync_interval, + dbo_prefill_token_threshold=self.dbo_prefill_token_threshold, + disable_nccl_for_dp_synchronization=self.disable_nccl_for_dp_synchronization, + enable_eplb=self.enable_eplb, + eplb_config=self.eplb_config, + expert_placement_strategy=self.expert_placement_strategy, + max_parallel_loading_workers=self.max_parallel_loading_workers, + disable_custom_all_reduce=self.disable_custom_all_reduce, + ray_workers_use_nsight=self.ray_workers_use_nsight, + ray_runtime_env=ray_runtime_env, + placement_group=placement_group, + distributed_executor_backend=self.distributed_executor_backend, + worker_cls=self.worker_cls, + worker_extension_cls=self.worker_extension_cls, + decode_context_parallel_size=self.decode_context_parallel_size, + dcp_comm_backend=self.dcp_comm_backend, + dcp_q_replicate=self.dcp_q_replicate, + dcp_kv_cache_interleave_size=self.dcp_kv_cache_interleave_size, + cp_kv_cache_interleave_size=( + self.cp_kv_cache_interleave_size + if self.cp_kv_cache_interleave_size is not None + else ParallelConfig.cp_kv_cache_interleave_size + ), + _allow_auto_resolve_cp_interleave_size=( + self.cp_kv_cache_interleave_size is None + ), + _api_process_count=self._api_process_count, + _api_process_rank=self._api_process_rank, + assigned_physical_gpu_ids=self._resolve_device_ids(), + enable_fault_tolerance=self.enable_fault_tolerance, + fault_tolerance_config=self.fault_tolerance_config, + numa_bind=self.numa_bind, + numa_bind_nodes=self.numa_bind_nodes, + numa_bind_cpus=self.numa_bind_cpus, + ) + + speculative_config = self.create_speculative_config( + target_model_config=model_config, + target_parallel_config=parallel_config, + ) + diffusion_config = self.create_diffusion_config() + watermark_config = self.create_watermark_config() + + self._set_default_max_num_seqs_and_batched_tokens_args( + usage_context, + model_config, + parallel_config, + ) + + assert self.max_num_batched_tokens is not None, ( + "max_num_batched_tokens must be set by this point" + ) + assert self.max_num_seqs is not None, "max_num_seqs must be set by this point" + assert self.enable_chunked_prefill is not None, ( + "enable_chunked_prefill must be set by this point" + ) + assert model_config.max_model_len is not None, ( + "max_model_len must be set by this point" + ) + scheduler_config = SchedulerConfig( + runner_type=model_config.runner_type, + max_num_batched_tokens=self.max_num_batched_tokens, + max_num_scheduled_tokens=self.max_num_scheduled_tokens, + max_num_seqs=self.max_num_seqs, + max_num_active_seqs=self.max_num_active_seqs, + max_num_queued_reqs=self.max_num_queued_reqs, + max_num_queued_tokens=self.max_num_queued_tokens, + max_model_len=model_config.max_model_len, + enable_chunked_prefill=self.enable_chunked_prefill, + disable_chunked_mm_input=self.disable_chunked_mm_input, + is_multimodal_model=model_config.is_multimodal_model, + is_encoder_decoder=model_config.is_encoder_decoder, + policy=self.scheduling_policy, + scheduler_cls=self.scheduler_cls, + long_prefill_token_threshold=self.long_prefill_token_threshold, + long_prefill_token_threshold_adaptive=( + self.long_prefill_token_threshold_adaptive + ), + scheduler_reserve_full_isl=self.scheduler_reserve_full_isl, + watermark=self.watermark, + prefill_schedule_interval=self.prefill_schedule_interval, + disable_hybrid_kv_cache_manager=self.disable_hybrid_kv_cache_manager, + async_scheduling=self.async_scheduling, + stream_interval=self.stream_interval, + ) + + if not model_config.is_multimodal_model and self.default_mm_loras: + raise ValueError( + "Default modality-specific LoRA(s) were provided for a " + "non multimodal model" + ) + + lora_config = ( + LoRAConfig( + max_lora_rank=self.max_lora_rank, + max_loras=self.max_loras, + default_mm_loras=self.default_mm_loras, + fully_sharded_loras=self.fully_sharded_loras, + lora_dtype=self.lora_dtype, + target_modules=self.lora_target_modules, + enable_tower_connector_lora=self.enable_tower_connector_lora, + specialize_active_lora=self.specialize_active_lora, + enable_mixed_moe_lora_format=self.enable_mixed_moe_lora_format, + enable_moe_shared_loras=self.enable_moe_shared_loras, + max_lora_cls_labels=self.max_lora_cls_labels, + max_cpu_loras=self.max_cpu_loras + if self.max_cpu_loras and self.max_cpu_loras > 0 + else None, + ) + if self.enable_lora + else None + ) + + if ( + lora_config is not None + and speculative_config is not None + and scheduler_config.max_num_batched_tokens + < ( + scheduler_config.max_num_seqs + * (speculative_config.num_speculative_tokens + 1) + ) + ): + raise ValueError( + "Consider increasing max_num_batched_tokens or " + "decreasing num_speculative_tokens" + ) + + # Attention config overrides + attention_config = copy.deepcopy(self.attention_config) + if self.attention_backend is not None: + if attention_config.backend is not None: + raise ValueError( + "attention_backend and attention_config.backend " + "are mutually exclusive" + ) + # Reuse the validator to handle "auto" and string-to-enum conversion + attention_config.backend = AttentionConfig.validate_backend_before( + self.attention_backend + ) + + # Batch-invariant mode requires deterministic attention behavior. + # If no backend is explicitly requested, prefer Triton Attention. + if ( + envs.VLLM_BATCH_INVARIANT + and attention_config.backend is None + and current_platform.is_xpu() + ): + attention_config.backend = AttentionBackendEnum.TRITON_ATTN + logger.info( + "VLLM_BATCH_INVARIANT is enabled and no attention backend was " + "specified; defaulting to TRITON_ATTN." + ) + + # TurboQuant requires FlashAttention 2 — FA3 boundary layers assert + # FlashAttentionImpl which fails with TurboQuantAttentionImpl. + # UltraQuant subclasses that impl, so it inherits the same limit. + if uses_packed_kv_backend and ( + attention_config.flash_attn_version is None + or attention_config.flash_attn_version >= 3 + ): + logger.warning( + "%s is not yet compatible with FlashAttention >= 3. " + "Overriding flash_attn_version to 2. To silence this " + "warning, pass --attention-config.flash_attn_version=2", + "TurboQuant" + if resolved_cache_dtype.startswith("turboquant_") + else "UltraQuant", + ) + attention_config.flash_attn_version = 2 + + # Mamba config overrides + mamba_config = copy.deepcopy(self.mamba_config) + # Convert string to enum if needed (CLI parsing returns a string) + if isinstance(self.mamba_backend, str): + mamba_config.backend = MambaBackendEnum[self.mamba_backend.upper()] + else: + mamba_config.backend = self.mamba_backend + if self.mamba_ssu_algorithm is not None: + mamba_config.ssu_algorithm = self.mamba_ssu_algorithm + if self.enable_mamba_cache_stochastic_rounding: + mamba_config.enable_stochastic_rounding = ( + self.enable_mamba_cache_stochastic_rounding + ) + if self.mamba_cache_philox_rounds: + mamba_config.stochastic_rounding_philox_rounds = ( + self.mamba_cache_philox_rounds + ) + mamba_config.validate_ssu_algorithm() + + # Kernel config overrides + kernel_config = copy.deepcopy(self.kernel_config) + if self.enable_flashinfer_autotune is not None: + if kernel_config.enable_flashinfer_autotune is not None: + raise ValueError( + "enable_flashinfer_autotune and " + "kernel_config.enable_flashinfer_autotune " + "are mutually exclusive" + ) + kernel_config.enable_flashinfer_autotune = self.enable_flashinfer_autotune + if self.moe_backend != "auto": + kernel_config.moe_backend = self.moe_backend + if self.linear_backend != "auto": + kernel_config.linear_backend = self.linear_backend + if self.sparse_indexer_topk_backend != "auto": + kernel_config.sparse_indexer_topk_backend = self.sparse_indexer_topk_backend + + # Transfer top-level ir_op_priority into KernelConfig.ir_op_priority + for op_name, op_priority in asdict(self.ir_op_priority).items(): + # Empty means unset + if not op_priority: + continue + + # Priority cannot be set 2x for the same op + if getattr(kernel_config.ir_op_priority, op_name): + raise ValueError( + f"Op priority for {op_name} specified via both ir_op_priority " + f"and KernelConfig.ir_op_priority, only one allowed at a time." + ) + + # Set the attribute + setattr(kernel_config.ir_op_priority, op_name, op_priority) + + load_config = self.create_load_config() + + # Pass reasoning_parser into StructuredOutputsConfig + self.create_structured_outputs_config() + + observability_config = self.create_observability_config() + + # Compilation config overrides + compilation_config = copy.deepcopy(self.compilation_config) + if self.cudagraph_capture_sizes is not None: + if compilation_config.cudagraph_capture_sizes is not None: + raise ValueError( + "cudagraph_capture_sizes and compilation_config." + "cudagraph_capture_sizes are mutually exclusive" + ) + compilation_config.cudagraph_capture_sizes = self.cudagraph_capture_sizes + if self.max_cudagraph_capture_size is not None: + if compilation_config.max_cudagraph_capture_size is not None: + raise ValueError( + "max_cudagraph_capture_size and compilation_config." + "max_cudagraph_capture_size are mutually exclusive" + ) + compilation_config.max_cudagraph_capture_size = ( + self.max_cudagraph_capture_size + ) + + offload_config = OffloadConfig( + offload_backend=self.offload_backend, + uva=UVAOffloadConfig( + cpu_offload_gb=self.cpu_offload_gb, + cpu_offload_params=self.cpu_offload_params, + ), + prefetch=PrefetchOffloadConfig( + offload_group_size=self.offload_group_size, + offload_num_in_group=self.offload_num_in_group, + offload_prefetch_step=self.offload_prefetch_step, + offload_params=self.offload_params, + ), + ) + + if self.gdn_prefill_backend is not None: + self.additional_config["gdn_prefill_backend"] = self.gdn_prefill_backend + if self.kda_prefill_backend is not None: + if ( + self.kda_prefill_backend == "flashkda" + and not current_platform.is_cuda() + ): + raise ValueError( + "--kda-prefill-backend=flashkda is only available on CUDA." + ) + if self.kda_prefill_backend == "fused" and not current_platform.is_rocm(): + raise ValueError( + "--kda-prefill-backend=fused is only available on ROCm." + ) + self.additional_config["kda_prefill_backend"] = self.kda_prefill_backend + if self.kda_decode_backend is not None: + self.additional_config["kda_decode_backend"] = self.kda_decode_backend + + config = VllmConfig( + model_config=model_config, + cache_config=cache_config, + parallel_config=parallel_config, + scheduler_config=scheduler_config, + device_config=device_config, + load_config=load_config, + offload_config=offload_config, + attention_config=attention_config, + aux_output_config=self.aux_output_config, + engram_config=self.engram_config, + mamba_config=mamba_config, + kernel_config=kernel_config, + lora_config=lora_config, + speculative_config=speculative_config, + watermark_config=watermark_config, + diffusion_config=diffusion_config, + structured_outputs_config=self.structured_outputs_config, + observability_config=observability_config, + logging_config=logging_config, + compilation_config=compilation_config, + kv_transfer_config=self.kv_transfer_config, + kv_events_config=self.kv_events_config, + ec_transfer_config=self.ec_transfer_config, + ec_manager_config=self.ec_manager_config, + reasoning_config=self.reasoning_config, + profiler_config=self.profiler_config, + additional_config=self.additional_config, + optimization_level=self.optimization_level, + performance_mode=self.performance_mode, + weight_transfer_config=self.weight_transfer_config, + shutdown_timeout=self.shutdown_timeout, + ) + + return config + + def _check_feature_supported(self): + """Raise an error if the feature is not supported.""" + if self.pipeline_parallel_size > 1: + supports_pp = getattr( + self.distributed_executor_backend, "supports_pp", False + ) + if not supports_pp and self.distributed_executor_backend not in ( + ParallelConfig.distributed_executor_backend, + "ray", + "mp", + "external_launcher", + ): + name = ( + "Pipeline Parallelism without Ray distributed " + "executor or multiprocessing executor or external " + "launcher" + ) + _raise_unsupported_error(feature_name=name) + + @classmethod + def get_batch_defaults( + cls, + world_size: int, + ) -> tuple[dict[UsageContext | None, int], dict[UsageContext | None, int]]: + from vllm.usage.usage_lib import UsageContext + + default_max_num_batched_tokens: dict[UsageContext | None, int] + default_max_num_seqs: dict[UsageContext | None, int] + + # When no user override, set the default values based on the usage + # context. + # Use different default values for different hardware. + + # Try to query the device name on the current platform. If it fails, + # it may be because the platform that imports vLLM is not the same + # as the platform that vLLM is running on (e.g. the case of scaling + # vLLM with Ray) and has no GPUs. In this case we use the default + # values for non-H100/H200 GPUs. + try: + device_memory = current_platform.get_device_total_memory() + device_name = current_platform.get_device_name().lower() + except Exception: + # This is only used to set default_max_num_batched_tokens + device_memory = 0 + device_name = "" + + # NOTE(Kuntai): Setting large `max_num_batched_tokens` for A100 reduces + # throughput, see PR #17885 for more details. + # So here we do an extra device name check to prevent such regression. + if device_memory >= 160 * GiB_bytes: + # for GPUs like B200/B300 with >= 160GB memory, use the largest defaults + default_max_num_batched_tokens = { + UsageContext.LLM_CLASS: 16384, + UsageContext.OPENAI_API_SERVER: 16384, + } + default_max_num_seqs = { + UsageContext.LLM_CLASS: 1024, + UsageContext.OPENAI_API_SERVER: 1024, + } + elif device_memory >= 70 * GiB_bytes and "a100" not in device_name: + # For GPUs like H100 and H200, use larger offline defaults. + default_max_num_batched_tokens = { + UsageContext.LLM_CLASS: 16384, + UsageContext.OPENAI_API_SERVER: 8192, + } + default_max_num_seqs = { + UsageContext.LLM_CLASS: 1024, + UsageContext.OPENAI_API_SERVER: 1024, + } + else: + # TODO(woosuk): Tune the default values for other hardware. + default_max_num_batched_tokens = { + UsageContext.LLM_CLASS: 8192, + UsageContext.OPENAI_API_SERVER: 2048, + } + default_max_num_seqs = { + UsageContext.LLM_CLASS: 256, + UsageContext.OPENAI_API_SERVER: 256, + } + + # tpu specific default values. + if current_platform.is_tpu(): + chip_name = current_platform.get_device_name() + + if chip_name == "V6E": + default_max_num_batched_tokens = { + UsageContext.LLM_CLASS: 2048, + UsageContext.OPENAI_API_SERVER: 1024, + } + elif chip_name == "V5E": + default_max_num_batched_tokens = { + UsageContext.LLM_CLASS: 1024, + UsageContext.OPENAI_API_SERVER: 512, + } + elif chip_name == "V5P": + default_max_num_batched_tokens = { + UsageContext.LLM_CLASS: 512, + UsageContext.OPENAI_API_SERVER: 256, + } + + # cpu specific default values. + if current_platform.is_cpu(): + default_max_num_batched_tokens = { + UsageContext.LLM_CLASS: 4096 * world_size, + UsageContext.OPENAI_API_SERVER: 2048 * world_size, + } + default_max_num_seqs = { + UsageContext.LLM_CLASS: 256 * world_size, + UsageContext.OPENAI_API_SERVER: 128 * world_size, + } + + return default_max_num_batched_tokens, default_max_num_seqs + + def _set_default_chunked_prefill_and_prefix_caching_args( + self, model_config: ModelConfig + ) -> None: + default_chunked_prefill = model_config.is_chunked_prefill_supported + default_prefix_caching = model_config.is_prefix_caching_supported + + if self.enable_chunked_prefill is None: + self.enable_chunked_prefill = default_chunked_prefill + + logger.debug( + "%s chunked prefill by default", + "Enabling" if default_chunked_prefill else "Disabling", + ) + elif ( + model_config.runner_type == "generate" + and not self.enable_chunked_prefill + and default_chunked_prefill + ): + logger.warning_once( + "This model does not officially support disabling chunked prefill. " + "Disabling this manually may cause the engine to crash " + "or produce incorrect outputs.", + ) + elif ( + model_config.runner_type == "pooling" + and self.enable_chunked_prefill + and not default_chunked_prefill + ): + logger.warning_once( + "This model does not officially support chunked prefill. " + "Enabling this manually may cause the engine to crash " + "or produce incorrect outputs.", + ) + + if self.enable_prefix_caching is None: + self.enable_prefix_caching = default_prefix_caching + + logger.debug( + "%s prefix caching by default", + "Enabling" if default_prefix_caching else "Disabling", + ) + elif ( + model_config.runner_type == "pooling" + and self.enable_prefix_caching + and not default_prefix_caching + ): + logger.warning_once( + "This model does not officially support prefix caching. " + "Enabling this manually may cause the engine to crash " + "or produce incorrect outputs.", + ) + + # Disable chunked prefill and prefix caching for: + # RISCV CPUs in V1 + if current_platform.is_cpu() and current_platform.get_cpu_architecture() in ( + CpuArchEnum.RISCV, + ): + logger.info( + "Chunked prefill is not supported for" + "RISC-V CPUs; " + "disabling it for V1 backend." + ) + self.enable_chunked_prefill = False + logger.info( + "Prefix caching is not supported for " + "RISC-V CPUs; " + "disabling it for V1 backend." + ) + self.enable_prefix_caching = False + + def _set_default_reasoning_config_args(self): + if not self.reasoning_parser: + return + if self.reasoning_config is None: + self.reasoning_config = ReasoningConfig() + self.reasoning_config.reasoning_parser = self.reasoning_parser + + @staticmethod + def _get_min_mm_batched_tokens( + model_config: ModelConfig, + ) -> tuple[int, str] | None: + """Get the minimum max_num_batched_tokens needed for a multimodal + prefix-LM model to process at least one item of any supported modality. + + Returns (token_count, modality_name) for the most expensive modality, + or None if the value cannot be determined at this stage. + """ + try: + from vllm.multimodal import MULTIMODAL_REGISTRY + + # get_processing_info returns the model's multimodal processing + # metadata (supported modalities, token limits) without loading + # model weights or generating dummy data. + info = MULTIMODAL_REGISTRY.get_processing_info(model_config) + mm_counts = {modality: 1 for modality in info.supported_mm_limits} + # get_mm_max_tokens_per_item returns pre-computed per-item token + # ceilings for models that override it (e.g., Gemma4), or None + # for models that rely on dummy-input profiling. When None is + # returned we bail out — no dummy generation is triggered here. + max_tokens = info.get_mm_max_tokens_per_item( + seq_len=model_config.max_model_len, + mm_counts=mm_counts, + ) + if max_tokens is not None: + modality = max(max_tokens, key=max_tokens.__getitem__) + return (max_tokens[modality], modality) + except Exception as e: + logger.warning("Failed to determine min multimodal batched tokens: %s", e) + return None + + def _set_default_max_num_seqs_and_batched_tokens_args( + self, + usage_context: UsageContext | None, + model_config: ModelConfig, + parallel_config: ParallelConfig, + ): + world_size = self.pipeline_parallel_size * self.tensor_parallel_size + ( + default_max_num_batched_tokens, + default_max_num_seqs, + ) = self.get_batch_defaults(world_size) + + orig_max_num_batched_tokens = self.max_num_batched_tokens + orig_max_num_seqs = self.max_num_seqs + + if self.max_num_batched_tokens is None: + if parallel_config.use_batched_dp_moe: + self.max_num_batched_tokens = ( + SchedulerConfig.DEFAULT_MAX_NUM_BATCHED_TOKENS_FOR_BATCHED_DP + ) + else: + self.max_num_batched_tokens = default_max_num_batched_tokens.get( + usage_context, + SchedulerConfig.DEFAULT_MAX_NUM_BATCHED_TOKENS, + ) + + if self.max_num_seqs is None: + self.max_num_seqs = default_max_num_seqs.get( + usage_context, + SchedulerConfig.DEFAULT_MAX_NUM_SEQS, + ) + + # If throughput mode is set, double max_num_batched_tokens and max_num_seqs. + if self.performance_mode == "throughput": + if orig_max_num_batched_tokens is None: + self.max_num_batched_tokens *= 2 + if orig_max_num_seqs is None: + self.max_num_seqs *= 2 + + if orig_max_num_batched_tokens is None: + assert model_config.max_model_len is not None, ( + "max_model_len must be set by this point" + ) + if not self.enable_chunked_prefill: + # If max_model_len is too short, use the default for higher throughput. + self.max_num_batched_tokens = max( + model_config.max_model_len, + self.max_num_batched_tokens, + ) + + # For multimodal prefix-LM models (e.g., Gemma 4) that disable + # chunked MM input, a single multimodal item must fit in one batch. + # Raise the floor to accommodate the largest per-item token count. + if model_config.is_multimodal_model and model_config.is_mm_prefix_lm: + result = self._get_min_mm_batched_tokens(model_config) + if result is not None and result[0] > self.max_num_batched_tokens: + mm_min, modality = result + logger.info( + "Raising max_num_batched_tokens from %d to %d to " + "accommodate '%s' input for prefix-LM model %s.", + self.max_num_batched_tokens, + mm_min, + modality, + model_config.model, + ) + self.max_num_batched_tokens = mm_min + + # When using default settings, + # Ensure max_num_batched_tokens does not exceed model limit. + # Some models (e.g., Whisper) have embeddings tied to max length. + self.max_num_batched_tokens = min( + self.max_num_seqs * model_config.max_model_len, + self.max_num_batched_tokens, + ) + + logger.debug( + "Defaulting max_num_batched_tokens to %d for %s usage context.", + self.max_num_batched_tokens, + usage_context.value if usage_context else None, + ) + + if orig_max_num_seqs is None: + assert self.max_num_batched_tokens is not None # For type checking + self.max_num_seqs = min(self.max_num_seqs, self.max_num_batched_tokens) + + logger.debug( + "Defaulting max_num_seqs to %d for %s usage context.", + self.max_num_seqs, + usage_context.value if usage_context else None, + ) + + +@dataclass +class AsyncEngineArgs(EngineArgs): + """Arguments for asynchronous vLLM engine.""" + + enable_log_requests: bool = False + + @staticmethod + def add_cli_args( + parser: FlexibleArgumentParser, async_args_only: bool = False + ) -> FlexibleArgumentParser: + # Initialize plugin to update the parser, for example, The plugin may + # add a new kind of quantization method to --quantization argument or + # a new device to --device argument. + load_general_plugins() + if not async_args_only: + parser = EngineArgs.add_cli_args(parser) + parser.add_argument( + "--enable-log-requests", + action=argparse.BooleanOptionalAction, + default=AsyncEngineArgs.enable_log_requests, + help="Enable logging request information, dependent on log level:\n" + "- INFO: Request ID, parameters and LoRA request.\n" + "- DEBUG: Prompt inputs (e.g: text, token IDs).\n" + "You can set the minimum log level via `VLLM_LOGGING_LEVEL`.", + ) + current_platform.pre_register_and_update(parser) + return parser + + +def _raise_unsupported_error(feature_name: str): + msg = ( + f"{feature_name} is not supported. We recommend to " + f"remove {feature_name} from your config." + ) + raise NotImplementedError(msg) diff --git a/tests/fixtures/canary/vllm-0.32.0/vllm/entrypoints/launchers/cli_args.py b/tests/fixtures/canary/vllm-0.32.0/vllm/entrypoints/launchers/cli_args.py new file mode 100644 index 00000000..e2c2625e --- /dev/null +++ b/tests/fixtures/canary/vllm-0.32.0/vllm/entrypoints/launchers/cli_args.py @@ -0,0 +1,572 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""This file contains the command line arguments for the vLLM's online server. +It is kept in a separate file for documentation purposes. +""" + +import argparse +import json +import ssl +from collections.abc import Sequence +from dataclasses import field +from typing import Any, Literal + +import vllm.envs as envs +from vllm.config import config +from vllm.engine.arg_utils import AsyncEngineArgs, optional_type +from vllm.entrypoints.anthropic.protocol import ( + AnthropicDisabledThinkingEffortOption, +) +from vllm.entrypoints.openai.models.protocol import LoRAModulePath +from vllm.logger import init_logger +from vllm.renderers.chat_utils import ( + ChatTemplateContentFormatOption, + validate_chat_template, +) +from vllm.tool_parsers import ToolParserManager +from vllm.tool_parsers.tool_strict_level import ToolStrictLevelName +from vllm.utils.argparse_utils import FlexibleArgumentParser + +from .utils.constants import ( + H11_MAX_HEADER_COUNT_DEFAULT, + H11_MAX_INCOMPLETE_EVENT_SIZE_DEFAULT, +) + +logger = init_logger(__name__) + + +class LoRAParserAction(argparse.Action): + def __call__( + self, + parser: argparse.ArgumentParser, + namespace: argparse.Namespace, + values: str | Sequence[str] | None, + option_string: str | None = None, + ): + if values is None: + values = [] + if isinstance(values, str): + raise TypeError("Expected values to be a list") + + lora_list: list[LoRAModulePath] = [] + for item in values: + if item in [None, ""]: # Skip if item is None or empty string + continue + if "=" in item and "," not in item: # Old format: name=path + name, path = item.split("=", 1) + lora_list.append(LoRAModulePath(name, path)) + else: # Assume JSON format + try: + lora_dict = json.loads(item) + lora = LoRAModulePath(**lora_dict) + lora_list.append(lora) + except json.JSONDecodeError: + parser.error(f"Invalid JSON format for --lora-modules: {item}") + except TypeError as e: + parser.error( + f"Invalid fields for --lora-modules: {item} - {str(e)}" + ) + setattr(namespace, self.dest, lora_list) + + +@config +class BaseFrontendArgs: + """Base arguments for the OpenAI-compatible frontend server. + + This base class does not include host, port, and server-specific arguments + like SSL, CORS, and HTTP server settings. Those arguments are added by + the subclasses. + """ + + lora_modules: list[LoRAModulePath] | None = None + """LoRA modules configurations in either 'name=path' format or JSON format + or JSON list format. Example (old format): `'name=path'` Example (new + format): `{\"name\": \"name\", \"path\": \"lora_path\", + \"base_model_name\": \"id\"}`""" + chat_template: str | None = None + """The file path to the chat template, or the template in single-line form + for the specified model.""" + chat_template_content_format: ChatTemplateContentFormatOption = "auto" + """The format to render message content within a chat template. + + * "string" will render the content as a string. Example: `"Hello World"` + * "openai" will render the content as a list of dictionaries, similar to + OpenAI schema. Example: `[{"type": "text", "text": "Hello world!"}]`""" + trust_request_chat_template: bool = False + """Whether to trust the chat template provided in the request. If False, + the server will always use the chat template specified by `--chat-template` + or the ones from tokenizer.""" + trust_request_mm_kwargs: bool = False + """Whether to trust per-request multimodal kwargs (`mm_processor_kwargs` + and `media_io_kwargs`). If False, the server rejects non-empty values + because they can change multimodal preprocessing resource usage. Only + enable this when API clients are trusted.""" + default_chat_template_kwargs: dict[str, Any] | None = None + """Default keyword arguments to pass to the chat template renderer. + These will be merged with request-level chat_template_kwargs, + with request values taking precedence. Useful for setting default + behavior for reasoning models. Example: '{"enable_thinking": false}' + to disable thinking mode by default for Qwen3/DeepSeek models.""" + response_role: str = "assistant" + """The role name to return if `request.add_generation_prompt=true`.""" + return_tokens_as_token_ids: bool = False + """When `--max-logprobs` is specified, represents single tokens as + strings of the form 'token_id:{token_id}' so that tokens that are not + JSON-encodable can be identified.""" + enable_auto_tool_choice: bool = False + """Enable auto tool choice for supported models. Use `--tool-call-parser` + to specify which parser to use.""" + exclude_tools_when_tool_choice_none: bool = False + """If specified, exclude tool definitions in prompts when + tool_choice='none'.""" + tool_call_parser: str | None = None + """Select the tool call parser depending on the model that you're using. + This is used to parse the model-generated tool call into OpenAI API format. + Required for `--enable-auto-tool-choice`. You can choose any option from + the built-in parsers or register a plugin via `--tool-parser-plugin`.""" + tool_parser_plugin: str = "" + """Special the tool parser plugin write to parse the model-generated tool + into OpenAI API format, the name register in this plugin can be used in + `--tool-call-parser`.""" + tool_strict_level: ToolStrictLevelName = "auto" + """Server-side floor for structural-tag based tool calling, applied on top + of the per-tool `strict` field. `auto` follows the request's tool choice + and per-tool strictness; `function` constrains the + tool-call envelope (markup and function name) for every request with + tools; `parameter` additionally pins argument schemas, as if every tool + were `strict: true`.""" + tool_server: str | None = None + """Comma-separated list of host:port pairs (IPv4, IPv6, or hostname). + Examples: 127.0.0.1:8000, [::1]:8000, localhost:1234. Or `demo` for + built-in demo tools (browser and Python code interpreter). WARNING: + The `demo` Python tool executes model-generated code in Docker without + network isolation by default. See the security guide for more + information.""" + max_log_len: int | None = None + """Max number of prompt characters or prompt ID numbers being printed in + log. The default of None means unlimited.""" + enable_prompt_tokens_details: bool = False + """If set to True, enable prompt_tokens_details in usage.""" + enable_per_request_metrics: bool = False + """If set to True, include per-request timing metrics in API responses.""" + enable_server_load_tracking: bool = False + """If set to True, enable tracking server_load_metrics in the app state.""" + enable_force_include_usage: bool = False + """If set to True, including usage on every request.""" + sse_keep_alive_interval: int = 0 + """Send an SSE keep-alive comment line every this many seconds when a + `/v1/chat/completions` or `/v1/completions` streaming response is idle + (queued, prefill, or between tokens), to prevent reverse proxies/tunnels + with read timeouts from closing the connection. Defaults to 0, which + disables keep-alive comments entirely. + """ + enable_tokenizer_info_endpoint: bool = False + """Enable the `/tokenizer_info` endpoint. May expose chat + templates and other tokenizer configuration.""" + enable_log_outputs: bool = False + """If set to True, log model outputs (generations). Requires + `--enable-log-requests`. Output text and finish reasons are logged at INFO, + while output token IDs are logged at DEBUG.""" + enable_log_deltas: bool = True + """If set to False, output deltas will not be logged. Relevant only if + --enable-log-outputs is set. + """ + cohere_is_reasoning_model: bool = True + """Cohere ``/cohere/v2/chat`` only. Whether the served model is a + reasoning Command-family model. When True (default), the assistant's + chain-of-thought is surfaced as a ``thinking`` content block (or + ``content-*`` events on the stream). When False, reasoning is + surfaced as Cohere's ``tool_plan`` field (or ``tool-plan-delta`` + events) whenever the model emits tool calls, matching older non- + reasoning Command models. Has no effect on the non-Cohere + endpoints.""" + cohere_format: str = "cmd4" + """Cohere ``--tokenizer-mode cohere`` only. Which Cohere prompt + format to render: ``cmd4`` (current Command A+ models; default) or + ``cmd3`` (earlier Cmd-A and Cmd-A reasoning models). Selecting the + wrong format silently produces a prompt the model wasn't trained + on, which most commonly manifests as the model emitting text but no + citations / tool calls / thinking blocks. Equivalent to passing + ``--default-chat-template-kwargs '{"cohere_format": "..."}'`` -- any + explicit request-level ``chat_template_kwargs.cohere_format`` takes + priority.""" + anthropic_disabled_thinking_effort: AnthropicDisabledThinkingEffortOption = "auto" + """Anthropic ``/v1/messages`` only. The ``reasoning_effort`` used for + requests with ``thinking: {"type": "disabled"}``. ``none`` turns thinking + off for models that support it; ``low`` suits models that always think + (e.g. GLM-5.3) or reject ``none`` (e.g. gpt-oss). ``auto`` (default) uses + ``low`` when the renderer rejects ``none`` or renders it the same as a + thinking effort, and ``none`` otherwise.""" + log_error_stack: bool = envs.VLLM_SERVER_DEV_MODE + """If set to True, log the stack trace of error responses""" + tokens_only: bool = False + """ + If set to True, only enable the Tokens In<>Out endpoint. + This is intended for use in a Disaggregated Everything setup. + """ + enable_scale_out: bool = False + """ + If set to True, register the scale-out endpoints (`/render`, `/derender`, + `/inference/v1/generate` and `/inference/v1/abort_requests`) on `vllm serve`. + Has no effect on `vllm launch render` or `vllm serve --tokens-only` which + always register their required endpoints regardless of this flag. + """ + fingerprint_mode: Literal["full", "hash", "custom", "none"] = "full" + """Controls the ``system_fingerprint`` field on responses. + + - ``full`` (default): ``vllm-<version>[-<parallelism>]-<hash8>``. Encodes + server version, non-trivial parallelism degrees (tp/pp/dp/ep), and an + 8-char config hash. + - ``hash``: ``vllm-<version>-<hash8>``. Parallelism stripped. + - ``custom``: emits the literal string from ``--fingerprint-value``. + - ``none``: the field is omitted (serialized as ``null``). + """ + fingerprint_value: str | None = None + """Literal fingerprint string used when ``--fingerprint-mode=custom``.""" + + @classmethod + def _customize_cli_kwargs( + cls, + frontend_kwargs: dict[str, Any], + ) -> dict[str, Any]: + """Customize argparse kwargs before arguments are registered. + + Subclasses should override this and call + ``super()._customize_cli_kwargs(frontend_kwargs)`` first. + """ + # Special case: default_chat_template_kwargs needs json.loads type + frontend_kwargs["default_chat_template_kwargs"]["type"] = json.loads + + # Special case: LoRA modules need custom parser action and + # optional_type(str) + frontend_kwargs["lora_modules"]["type"] = optional_type(str) + frontend_kwargs["lora_modules"]["action"] = LoRAParserAction + + # Special case: Tool call parser shows built-in options. + valid_tool_parsers = list(ToolParserManager.list_registered()) + parsers_str = ",".join(valid_tool_parsers) + frontend_kwargs["tool_call_parser"]["metavar"] = ( + f"{{{parsers_str}}} or name registered in --tool-parser-plugin" + ) + return frontend_kwargs + + @classmethod + def add_cli_args(cls, parser: FlexibleArgumentParser) -> FlexibleArgumentParser: + """Register CLI arguments for this frontend class. + + Subclasses should override ``_customize_cli_kwargs`` instead of + this method so that base-class postprocessing is always applied. + """ + from vllm.engine.arg_utils import get_kwargs + + frontend_kwargs = get_kwargs(cls) + frontend_kwargs = cls._customize_cli_kwargs(frontend_kwargs) + + group_name = cls.__name__.replace("Args", "") + frontend_group = parser.add_argument_group( + title=group_name, + description=cls.__doc__, + ) + for key, value in frontend_kwargs.items(): + extra_flags = value.pop("flags", []) + frontend_group.add_argument( + *extra_flags, f"--{key.replace('_', '-')}", **value + ) + + return parser + + +@config +class FrontendArgs(BaseFrontendArgs): + """Arguments for the OpenAI-compatible frontend server.""" + + host: str | None = None + """Host name.""" + port: int = 8000 + """Port number.""" + grpc_port: int | None = None + """Enable the Rust frontend's additional gRPC Inference and Control services + on this port. Requires `VLLM_USE_RUST_FRONTEND=1 vllm serve`; HTTP remains on + `--port`. Binds on `--host`, or 127.0.0.1 with `--uds`. Cannot be combined + with the Python gRPC server's `--grpc` flag.""" + data_parallel_supervisor_port: int = 9256 + """HTTP port for aggregated health endpoints in multi-port external LB + mode.""" + dp_supervisor_probe_interval_s: float = 5.0 + """Seconds between aggregated health probes in multi-port external LB mode.""" + dp_supervisor_probe_timeout_s: float = 5.0 + """Seconds to wait between retries when a child health probe fails with a + connection error in multi-port external LB mode.""" + dp_supervisor_probe_failure_threshold: int = 3 + """Number of consecutive connection-error retries before a child health + probe is declared failed in multi-port external LB mode.""" + uds: str | None = None + """Unix domain socket path. If set, host and port arguments are ignored.""" + uvicorn_log_level: Literal[ + "critical", "error", "warning", "info", "debug", "trace" + ] = "info" + """Log level for uvicorn.""" + disable_uvicorn_access_log: bool = False + """Disable uvicorn access log.""" + disable_access_log_for_endpoints: str | None = None + """Comma-separated list of endpoint paths to exclude from uvicorn access + logs. This is useful to reduce log noise from high-frequency endpoints + like health checks. Example: "/health,/metrics,/ping". + When set, access logs for requests to these paths will be suppressed + while keeping logs for other endpoints.""" + allow_credentials: bool = False + """Allow credentials.""" + allowed_origins: list[str] = field(default_factory=list) + """Allowed origins. Cross-origin browser access is disabled by default.""" + allowed_methods: list[str] = field(default_factory=lambda: ["*"]) + """Allowed methods.""" + allowed_headers: list[str] = field(default_factory=lambda: ["*"]) + """Allowed headers.""" + api_key: list[str] | None = None + """If provided, the server will require one of these keys to be presented in + the header. + + Warning: this only authenticates endpoints under the `/v1`, `/v2`, and + `/inference` path prefixes. Other endpoints on the same server, including + `/invocations` (which exposes the same inference capabilities as `/v1`), + remain unauthenticated. Do not rely on `--api-key` alone to secure vLLM; + see + https://docs.vllm.ai/en/latest/usage/security.html#api-key-authentication-limitations + for what it does and does not protect.""" + ssl_keyfile: str | None = None + """The file path to the SSL key file.""" + ssl_certfile: str | None = None + """The file path to the SSL cert file.""" + ssl_ca_certs: str | None = None + """The CA certificates file.""" + enable_ssl_refresh: bool = False + """Refresh SSL Context when SSL certificate files change""" + ssl_cert_reqs: int = int(ssl.CERT_NONE) + """Whether client certificate is required (see stdlib ssl module's).""" + ssl_ciphers: str | None = None + """SSL cipher suites for HTTPS (TLS 1.2 and below only). + Example: 'ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-CHACHA20-POLY1305'""" + root_path: str | None = None + """FastAPI root_path when app is behind a path based routing proxy.""" + middleware: list[str] = field(default_factory=lambda: []) + """Additional ASGI middleware to apply to the app. We accept multiple + --middleware arguments. The value should be an import path. If a function + is provided, vLLM will add it to the server using + `@app.middleware('http')`. If a class is provided, vLLM will + add it to the server using `app.add_middleware()`.""" + enable_request_id_headers: bool = False + """If specified, API server will add X-Request-Id header to responses.""" + disable_fastapi_docs: bool = False + """Disable FastAPI's OpenAPI schema, Swagger UI, and ReDoc endpoint.""" + h11_max_incomplete_event_size: int = H11_MAX_INCOMPLETE_EVENT_SIZE_DEFAULT + """Maximum size (bytes) of an incomplete HTTP event (header or body) for + h11 parser. Helps mitigate header abuse. Default: 4194304 (4 MB).""" + h11_max_header_count: int = H11_MAX_HEADER_COUNT_DEFAULT + """Maximum number of HTTP headers allowed in a request for h11 parser. + Helps mitigate header abuse. Default: 256.""" + enable_offline_docs: bool = False + """ + Enable offline FastAPI documentation for air-gapped environments. + Uses vendored static assets bundled with vLLM. + """ + enable_flash_late_interaction: bool = True + """If set, run pooling score MaxSim on GPU in the API server process. + Can significantly improve late-interaction scoring performance. + When disabled, the setting is also propagated to the engine's + `PoolerConfig`, so the engine-side scorer uses the reference MaxSim + path instead of the fused Triton kernel.""" + + @classmethod + def _customize_cli_kwargs( + cls, + frontend_kwargs: dict[str, Any], + ) -> dict[str, Any]: + frontend_kwargs = super()._customize_cli_kwargs(frontend_kwargs) + + # Special case: allowed_origins, allowed_methods, allowed_headers all + # need json.loads type + # Should also remove nargs + frontend_kwargs["allowed_origins"]["type"] = json.loads + frontend_kwargs["allowed_methods"]["type"] = json.loads + frontend_kwargs["allowed_headers"]["type"] = json.loads + del frontend_kwargs["allowed_origins"]["nargs"] + del frontend_kwargs["allowed_methods"]["nargs"] + del frontend_kwargs["allowed_headers"]["nargs"] + + # Special case: Middleware needs to append action + frontend_kwargs["middleware"]["action"] = "append" + frontend_kwargs["middleware"]["type"] = str + if "nargs" in frontend_kwargs["middleware"]: + del frontend_kwargs["middleware"]["nargs"] + frontend_kwargs["middleware"]["default"] = [] + + # Special case: disable_access_log_for_endpoints is a single + # comma-separated string, not a list + if "nargs" in frontend_kwargs["disable_access_log_for_endpoints"]: + del frontend_kwargs["disable_access_log_for_endpoints"]["nargs"] + + return frontend_kwargs + + +def resolve_default_chat_template_kwargs( + args: argparse.Namespace, +) -> dict[str, Any]: + """Resolve renderer defaults, including the dedicated Cohere format flag.""" + kwargs = dict(args.default_chat_template_kwargs or {}) + if getattr(args, "cohere_format", None): + kwargs.setdefault("cohere_format", args.cohere_format) + return kwargs + + +def make_arg_parser(parser: FlexibleArgumentParser) -> FlexibleArgumentParser: + """Create the CLI argument parser used by the OpenAI API server. + + We rely on the helper methods of `FrontendArgs` and `AsyncEngineArgs` to + register all arguments instead of manually enumerating them here. This + avoids code duplication and keeps the argument definitions in one place. + """ + parser.add_argument( + "model_tag", + type=str, + nargs="?", + help="The model tag to serve (optional if specified in config)", + ) + parser.add_argument( + "--headless", + action="store_true", + default=False, + help="Run in headless mode. See multi-node data parallel " + "documentation for more details.", + ) + parser.add_argument( + "--api-server-count", + "-asc", + type=int, + default=None, + help="How many API server processes to run. " + "Defaults to data_parallel_size if not specified.", + ) + parser.add_argument( + "--config", + help="Read CLI options from a config file. " + "Must be a YAML with the following options: " + "https://docs.vllm.ai/en/latest/configuration/serve_args.html", + ) + parser.add_argument( + "--grpc", + action="store_true", + default=False, + help="Launch the Python gRPC (SMG VllmEngine) server on --port instead " + "of the HTTP OpenAI-compatible server. Requires: pip install vllm[grpc]. " + "Cannot be combined with --grpc-port, which enables the Rust " + "frontend's gRPC services.", + ) + parser = FrontendArgs.add_cli_args(parser) + parser = AsyncEngineArgs.add_cli_args(parser) + + return parser + + +def validate_grpc_port_arg(args: argparse.Namespace) -> None: + """Validate the Rust frontend's optional gRPC listener.""" + if getattr(args, "grpc_port", None) is None: + return + + if args.grpc: + raise ValueError( + "--grpc and --grpc-port are mutually exclusive. Use --grpc " + "--port for the Python gRPC server, or VLLM_USE_RUST_FRONTEND=1 " + "with --grpc-port for the Rust frontend." + ) + if not envs.VLLM_USE_RUST_FRONTEND or getattr(args, "subparser", None) != "serve": + raise ValueError("--grpc-port requires VLLM_USE_RUST_FRONTEND=1 vllm serve") + if args.headless or ( + args.api_server_count is not None and args.api_server_count <= 0 + ): + raise ValueError( + "--grpc-port requires a Rust frontend; remove --headless " + "and non-positive --api-server-count" + ) + if args.data_parallel_multi_port_external_lb: + raise ValueError( + "--grpc-port is incompatible with " + "--data-parallel-multi-port-external-lb: " + "its frontends would share the same gRPC port" + ) + if not 0 <= args.grpc_port <= 65535: + raise ValueError("--grpc-port must be between 0 and 65535") + if args.grpc_port != 0 and args.grpc_port == args.port and not args.uds: + raise ValueError("--grpc-port must differ from --port when HTTP uses TCP") + + +def validate_parsed_serve_args(args: argparse.Namespace): + """Quick checks for model serve args that raise prior to loading.""" + # `vllm launch <component>` builds its parser with make_arg_parser too (see + # LaunchSubcommandBase.add_cli_args), so its args are serve args as well. + if hasattr(args, "subparser") and args.subparser not in ("serve", "launch"): + return + + # Ensure that the chat template is valid; raises if it likely isn't + validate_chat_template(args.chat_template) + if args.chat_template is not None and "hf" in ( + args.tool_call_parser, + getattr(args, "reasoning_parser", None), + ): + logger.warning( + "--chat-template is set; the hf parser still expects " + "the checkpoint's output format." + ) + + validate_grpc_port_arg(args) + + # Enable auto tool needs a tool call parser to be valid + if args.enable_auto_tool_choice and not args.tool_call_parser: + raise TypeError("Error: --enable-auto-tool-choice requires --tool-call-parser") + if args.enable_log_outputs and not args.enable_log_requests: + raise TypeError("Error: --enable-log-outputs requires --enable-log-requests") + + # SSE keep-alive interval must be zero (disabled) or a positive integer. + if getattr(args, "sse_keep_alive_interval", 0) < 0: + raise ValueError( + "Error: --sse-keep-alive-interval must be a non-negative integer " + "(0 disables keep-alive comments)." + ) + + if getattr(args, "enable_per_request_metrics", False) and getattr( + args, "disable_log_stats", False + ): + raise ValueError( + "Error: --enable-per-request-metrics requires engine statistics " + "logging; remove --disable-log-stats to enable per-request metrics." + ) + + if args.data_parallel_multi_port_external_lb: + from vllm.entrypoints.launchers.dp_supervisor import ( + validate_multi_port_external_lb_args, + ) + + validate_multi_port_external_lb_args(args) + + +def create_parser_for_docs() -> FlexibleArgumentParser: + parser_for_docs = FlexibleArgumentParser( + prog="-m vllm.entrypoints.launchers.api_server.entry" + ) + return make_arg_parser(parser_for_docs) + + +def propagate_flash_late_interaction(args, engine_args) -> None: + """Propagate `--no-enable-flash-late-interaction` into the engine config. + + The frontend flag alone only disables the API-server scoring path; + mirroring it into `PoolerConfig.enable_flash_late_interaction` lets the + engine-side scorer fall back to the reference MaxSim path too. + """ + if getattr(args, "enable_flash_late_interaction", True): + return + from vllm.config.pooler import PoolerConfig + + if engine_args.pooler_config is None: + engine_args.pooler_config = PoolerConfig(enable_flash_late_interaction=False) + else: + engine_args.pooler_config.enable_flash_late_interaction = False diff --git a/tests/fixtures/canary/vllm-0.32.0/vllm/envs.py b/tests/fixtures/canary/vllm-0.32.0/vllm/envs.py new file mode 100644 index 00000000..a2b46030 --- /dev/null +++ b/tests/fixtures/canary/vllm-0.32.0/vllm/envs.py @@ -0,0 +1,2488 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import functools +import json +import logging +import os +import sys +import tempfile +import uuid +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, Literal + +if TYPE_CHECKING: + VLLM_HOST_IP: str = "" + VLLM_PORT: int | None = None + VLLM_RPC_BASE_PATH: str = tempfile.gettempdir() + VLLM_USE_MODELSCOPE: bool = False + VLLM_USE_FASTOKENS: bool = False + VLLM_RINGBUFFER_WARNING_INTERVAL: int = 60 + VLLM_NCCL_SO_PATH: str | None = None + LD_LIBRARY_PATH: str | None = None + VLLM_ROCM_SLEEP_MEM_CHUNK_SIZE: int = 256 + LOCAL_RANK: int = 0 + CUDA_VISIBLE_DEVICES: str | None = None + VLLM_ENGINE_READY_TIMEOUT_S: int = 600 + VLLM_CHAT_TEMPLATE_RENDER_TIMEOUT: float = 30.0 + VLLM_API_KEY: str | None = None + VLLM_DEBUG_LOG_API_SERVER_RESPONSE: bool = False + S3_ACCESS_KEY_ID: str | None = None + S3_SECRET_ACCESS_KEY: str | None = None + S3_ENDPOINT_URL: str | None = None + VLLM_MODEL_REDIRECT_PATH: str | None = None + VLLM_CACHE_ROOT: str = os.path.expanduser("~/.cache/vllm") + VLLM_CONFIG_ROOT: str = os.path.expanduser("~/.config/vllm") + VLLM_USAGE_STATS_SERVER: str = "https://stats.vllm.ai" + VLLM_NO_USAGE_STATS: bool = False + VLLM_DO_NOT_TRACK: bool = False + VLLM_USAGE_SOURCE: str = "production" + VLLM_CONFIGURE_LOGGING: bool = True + VLLM_LOGGING_LEVEL: str = "INFO" + VLLM_LOGGING_PREFIX: str = "" + VLLM_LOGGING_STREAM: str = "ext://sys.stdout" + VLLM_LOGGING_CONFIG_PATH: str | None = None + VLLM_LOGGING_COLOR: str = "auto" + NO_COLOR: bool = False + FORCE_COLOR: bool = False + VLLM_LOG_STATS_INTERVAL: float = 10.0 + VLLM_TRACE_FUNCTION: int = 0 + VLLM_USE_FLASHINFER_SAMPLER: bool = True + VLLM_PP_LAYER_PARTITION: str | None = None + VLLM_CPU_KVCACHE_SPACE: int | None = 0 + VLLM_CPU_OMP_THREADS_BIND: str = "auto" + VLLM_CPU_NUM_OF_RESERVED_CPU: int | None = None + VLLM_CPU_CI_ENV: bool = False + VLLM_CPU_ATTN_SPLIT_KV: bool = True + VLLM_ZENTORCH_WEIGHT_PREPACK: bool = True + VLLM_CPU_INT4_W4A8: bool = True + VLLM_XLA_CACHE_PATH: str = os.path.join(VLLM_CACHE_ROOT, "xla_cache") + VLLM_XLA_CHECK_RECOMPILATION: bool = False + VLLM_SPARSE_INDEXER_MAX_LOGITS_MB: int = 512 + VLLM_ADAPTIVE_VERIFICATION_PROFILE_CONTEXT_LEN: int = 8192 + VLLM_USE_RAY_COMPILED_DAG_CHANNEL_TYPE: Literal["auto", "nccl", "shm"] = "auto" + VLLM_USE_RAY_COMPILED_DAG_OVERLAP_COMM: bool = False + VLLM_USE_RAY_WRAPPED_PP_COMM: bool = True + VLLM_XPU_PP_MICROBATCH: bool = True + VLLM_USE_RAY_V2_EXECUTOR_BACKEND: bool = False + VLLM_DISTRIBUTED_USE_SPLIT_GROUP: bool = False + VLLM_XLA_USE_SPMD: bool = False + VLLM_WORKER_MULTIPROC_METHOD: Literal["fork", "spawn"] = "fork" + VLLM_ASSETS_CACHE: str = os.path.join(VLLM_CACHE_ROOT, "assets") + VLLM_ASSETS_CACHE_MODEL_CLEAN: bool = False + VLLM_IMAGE_FETCH_TIMEOUT: int = 5 + VLLM_VIDEO_FETCH_TIMEOUT: int = 30 + VLLM_AUDIO_FETCH_TIMEOUT: int = 10 + VLLM_MEDIA_CACHE: str = "" + VLLM_MEDIA_CACHE_MAX_SIZE_MB: int = 5120 + VLLM_MEDIA_CACHE_TTL_HOURS: float = 24 + VLLM_MEDIA_FETCH_MAX_RETRIES: int = 3 + VLLM_MAX_MEDIA_DOWNLOAD_SIZE_MB: int = 256 + VLLM_MEDIA_URL_ALLOW_REDIRECTS: bool = True + VLLM_MEDIA_LOADING_THREAD_COUNT: int = 8 + VLLM_MAX_AUDIO_CLIP_FILESIZE_MB: int = 25 + VLLM_MAX_AUDIO_DECODE_DURATION_S: int = 600 + VLLM_MAX_AUDIO_DECODE_BYTES: int = 268_435_456 + VLLM_MAX_AUDIO_PREPROCESS_WORKERS: int = max(1, min(os.cpu_count() or 1, 2)) + VLLM_MAX_EMBED_DECODE_BYTES: int = 2_147_483_648 + VLLM_MAX_IMAGE_PIXELS: int = 178_956_970 + VLLM_VIDEO_LOADER_BACKEND: str = "opencv" + VLLM_MEDIA_CONNECTOR: str = "http" + VLLM_TARGET_DEVICE: str = "cuda" + VLLM_MAIN_CUDA_VERSION: str = "13.0" + VLLM_FLOAT32_MATMUL_PRECISION: Literal["highest", "high", "medium"] = "highest" + VLLM_BATCH_INVARIANT: bool = False + VLLM_TRITON_USE_TD: bool | None = None + VLLM_GPU_SYNC_CHECK: Literal["warn", "error"] | None = None + MAX_JOBS: str | None = None + NVCC_THREADS: str | None = None + VLLM_USE_PRECOMPILED: bool = False + VLLM_USE_PRECOMPILED_RUST: bool = False + VLLM_SKIP_PRECOMPILED_VERSION_SUFFIX: bool = False + VLLM_DOCKER_BUILD_CONTEXT: bool = False + VLLM_BUILD_COMMIT: str = "unknown" + VLLM_BUILD_PIPELINE: str = "local" + VLLM_BUILD_URL: str = "" + VLLM_IMAGE_TAG: str = "" + VLLM_KEEP_ALIVE_ON_ENGINE_DEATH: bool = False + CMAKE_BUILD_TYPE: Literal["Debug", "Release", "RelWithDebInfo"] | None = None + VERBOSE: bool = False + VLLM_ALLOW_LONG_MAX_MODEL_LEN: bool = False + VLLM_HTTP_TIMEOUT_KEEP_ALIVE: int = 5 # seconds + VLLM_MAX_N_SEQUENCES: int = 16384 + VLLM_MAX_COMPLETION_PROMPTS: int = 1024 + VLLM_MAX_STOP_STRINGS: int = 4 + VLLM_MAX_NUM_BAD_WORDS: int = 128 + VLLM_MAX_BAD_WORDS_TOTAL_TOKENS: int = 1024 + VLLM_PLUGINS: list[str] | None = None + VLLM_LORA_RESOLVER_CACHE_DIR: str | None = None + VLLM_LORA_RESOLVER_HF_REPO_LIST: str | None = None + VLLM_USE_AOT_COMPILE: bool = False + VLLM_USE_BYTECODE_HOOK: bool = True + VLLM_FORCE_AOT_LOAD: bool = False + VLLM_USE_MEGA_AOT_ARTIFACT: bool = False + VLLM_USE_TRITON_AWQ: bool = False + VLLM_FASTSAFETENSORS_QUEUE_SIZE: int = 0 + VLLM_TRITON_FORCE_FIRST_CONFIG: bool = False + VLLM_TRITON_JIT_WARMUP_NUM_THREADS: int = 4 + VLLM_ALLOW_RUNTIME_LORA_UPDATING: bool = False + VLLM_SKIP_P2P_CHECK: bool = False + VLLM_DISABLED_KERNELS: list[str] = [] + VLLM_USE_HW_AGNOSTIC: bool = False + VLLM_ENABLE_FLA_PACKED_RECURRENT_DECODE: bool = True + VLLM_GDN_DECODE_KERNEL: Literal["cuda", "triton"] = "cuda" + VLLM_DISABLE_PYNCCL: bool = False + VLLM_USE_OINK_OPS: bool = False + VLLM_MXFP4_EMULATION_DEQUANT_AT_LOAD: bool = False + VLLM_MXFP8_EMULATION_DEQUANT_AT_LOAD: bool = True + VLLM_ROCM_USE_AITER: bool = False + VLLM_ROCM_USE_AITER_CUSTOM_AR: bool = True + VLLM_ROCM_USE_AITER_LINEAR: bool = True + VLLM_ROCM_USE_AITER_LINEAR_HIPBMM: bool = False + VLLM_ROCM_USE_AITER_MOE: bool = True + VLLM_ROCM_AITER_MOE_DISPATCH_POLICY: int = 0 + VLLM_ROCM_USE_AITER_MOE_A4W4_DSV4: bool | None = None + VLLM_ROCM_USE_AITER_MOE_SITUV2: Literal["auto", "a4w4", "a8w4", "a16w4"] = "auto" + VLLM_ROCM_USE_AITER_RMSNORM: bool = True + VLLM_ROCM_USE_AITER_MLA: bool = True + VLLM_ROCM_AITER_MLA_ASM_PADDING: Literal["auto", "gluon", "asm"] = "auto" + VLLM_ROCM_AITER_MLA_DCP_VERIFY: Literal["auto", "asm", "segmented"] = "auto" + VLLM_ROCM_USE_AITER_MHA: bool = True + VLLM_ROCM_USE_AITER_FP4_ASM_GEMM: bool = False + VLLM_ROCM_USE_AITER_TRITON_SPARSE_MLA: bool = False + VLLM_ROCM_USE_AITER_TRITON_ROPE: bool = False + VLLM_ROCM_USE_AITER_FP8BMM: bool = True + VLLM_ROCM_USE_AITER_FP4BMM: bool = True + VLLM_ROCM_USE_AITER_UNIFIED_ATTENTION: bool = False + VLLM_ROCM_USE_AITER_FUSION_SHARED_EXPERTS: bool = False + VLLM_ROCM_AITER_MEGA_MOE_FUSE_SHARED_EXPERTS: bool = False + VLLM_ROCM_MONO_DECODE: bool = False + VLLM_ROCM_USE_AITER_TRITON_GEMM: bool = True + VLLM_ROCM_USE_SKINNY_GEMM: bool = True + VLLM_ROCM_FP8_PADDING: bool = True + VLLM_ROCM_MOE_PADDING: bool = True + VLLM_ROCM_SHUFFLE_KV_CACHE_LAYOUT: bool = False + VLLM_ENABLE_V1_MULTIPROCESSING: bool = True + VLLM_LOG_BATCHSIZE_INTERVAL: float = -1 + VLLM_DISABLE_COMPILE_CACHE: bool = False + VLLM_REPLICATE_EMBED: bool = False + VLLM_USE_LAYERNAME: bool = True + VLLM_USE_RUST_FRONTEND: bool = False + VLLM_USE_RUST_BENCH: bool = False + VLLM_RUST_FRONTEND_PATH: str | None = "auto" + VLLM_SERVER_DEV_MODE: bool = False + VLLM_V1_OUTPUT_PROC_CHUNK_SIZE: int = 128 + VLLM_MLA_DISABLE: bool = False + VLLM_RAY_PER_WORKER_GPUS: float = 1.0 + VLLM_RAY_BUNDLE_INDICES: str = "" + VLLM_CUDART_SO_PATH: str | None = None + VLLM_DP_RANK: int = 0 + VLLM_DP_RANK_LOCAL: int = -1 + VLLM_DP_SIZE: int = 1 + VLLM_USE_STANDALONE_COMPILE: bool = True + VLLM_ENABLE_PREGRAD_PASSES: bool = True + VLLM_USE_BREAKABLE_CUDAGRAPH: bool = False + VLLM_DP_MASTER_IP: str = "" + VLLM_DP_MASTER_PORT: int = 0 + VLLM_RANDOMIZE_DP_DUMMY_INPUTS: bool = False + VLLM_RAY_DP_PACK_STRATEGY: Literal["strict", "fill", "span"] = "strict" + VLLM_RAY_DP_PLACEMENT_NODE_IPS: str = "" + VLLM_RAY_EXTRA_ENV_VAR_PREFIXES_TO_COPY: str = "" + VLLM_RAY_EXTRA_ENV_VARS_TO_COPY: str = "" + VLLM_MARLIN_USE_ATOMIC_ADD: bool = False + VLLM_MARLIN_INPUT_DTYPE: Literal["int8", "fp8"] | None = None + VLLM_HUMMING_ONLINE_QUANT_CONFIG: dict[str, Any] | None = None + VLLM_HUMMING_INPUT_QUANT_CONFIG: dict[str, Any] | None = None + VLLM_HUMMING_USE_F16_ACCUM: bool = False + VLLM_HUMMING_MOE_GEMM_TYPE: Literal["indexed", "grouped", "auto"] | None = None + VLLM_B12X_MOE_FP4_FORCE_A16: bool = False + VLLM_DEEPEPLL_NVFP4_DISPATCH: bool = False + VLLM_V1_USE_OUTLINES_CACHE: bool = False + VLLM_TPU_USING_PATHWAYS: bool = False + VLLM_USE_DEEP_GEMM: bool = True + VLLM_MOE_USE_DEEP_GEMM: bool = True + VLLM_USE_DEEP_GEMM_E8M0: bool = True + VLLM_USE_DEEP_GEMM_TMA_ALIGNED_SCALES: bool = True + VLLM_DCP_Q_REPLICATE: bool = False + VLLM_USE_DIRECT_DCP_A2A: bool | None = None + VLLM_USE_DIRECT_DCP_Q_GATHER: bool | None = None + VLLM_USE_DIRECT_DCP_KV_GATHER: bool | None = None + VLLM_DEEP_GEMM_WARMUP: Literal[ + "skip", + "full", + "relax", + ] = "relax" + VLLM_USE_FUSED_MOE_GROUPED_TOPK: bool = True + VLLM_MOE_SKIP_PADDING: bool = True + VLLM_KIMI_K3_SHARD_SP_SHARED_EXPERT: bool = False + VLLM_KIMI_K3_AUX_ATTN_RES_STREAM: bool = False + VLLM_KIMI_K3_GEMM_AR: bool = True + VLLM_ENABLE_GEMM_RS: bool = False + VLLM_BLOCKSCALE_FP8_GEMM_FLASHINFER: bool = True + VLLM_USE_FLASHINFER_MOE_INT4: bool = False + VLLM_FLASHINFER_AUTOTUNE_CACHE_DIR: str | None = None + VLLM_FLASHINFER_AUTOTUNE_SKIP_OPS: list[str] | None = None + VLLM_FLASHINFER_ALLREDUCE_BACKEND: Literal["auto", "trtllm", "mnnvl"] = "auto" + VLLM_FLASHINFER_WORKSPACE_BUFFER_SIZE: int = 394 * 1024 * 1024 + VLLM_FLASHINFER_MOE_A2A_LOW_PRECISION_COMBINE: bool = False + VLLM_XGRAMMAR_CACHE_MB: int = 0 + VLLM_REGEX_COMPILATION_TIMEOUT_S: int = 5 + VLLM_MSGPACK_ZERO_COPY_THRESHOLD: int = 256 + VLLM_ALLOW_INSECURE_SERIALIZATION: bool = False + VLLM_DISABLE_REQUEST_ID_RANDOMIZATION: bool = False + VLLM_NIXL_SIDE_CHANNEL_HOST: str = "localhost" + VLLM_NIXL_SIDE_CHANNEL_PORT: int = 5600 + VLLM_P2P_SIDE_CHANNEL_HOST: str = "localhost" + VLLM_P2P_SIDE_CHANNEL_PORT: int = 5710 + VLLM_P2P_MAX_PEERS: int = 128 + VLLM_P2P_HANDSHAKE_TIMEOUT_S: int = 30 + VLLM_P2P_IDLE_TIMEOUT_S: int = 300 + VLLM_EC_SIDE_CHANNEL_HOST: str = "localhost" + VLLM_EC_SIDE_CHANNEL_PORT: int = 5601 + VLLM_MOONCAKE_BOOTSTRAP_PORT: int = 8998 + VLLM_MOONCAKE_STORE_TIER_LOG: bool = False + VLLM_MOONCAKE_LOAD_RECV_THREADS: int = 1 + VLLM_MOONCAKE_DISK_STAGING_USABLE_RATIO: float = 0.9 + MOONCAKE_PREFERRED_SEGMENT: str | None = None + MOONCAKE_REQUESTER_LOCAL_HOSTNAME: str | None = None + VLLM_MAX_TOKENS_PER_EXPERT_FP4_MOE: int = 163840 + VLLM_TOOL_PARSE_REGEX_TIMEOUT_SECONDS: int = 1 + VLLM_ENFORCE_STRICT_TOOL_CALLING: bool = True + VLLM_MQ_MAX_CHUNK_BYTES_MB: int = 16 + VLLM_EXECUTE_MODEL_TIMEOUT_SECONDS: int = 300 + VLLM_WORKER_SHUTDOWN_TIMEOUT_SECONDS: int = 5 + VLLM_KV_CACHE_LAYOUT: ( + Literal["LBNHC", "LBHNC", "LHBNC", "NHD", "HND", "BLHNC", "BLNHC", "BHLNC"] + | None + ) = None + VLLM_SSM_CONV_STATE_LAYOUT: Literal["SD", "DS"] | None = None + VLLM_COMPUTE_NANS_IN_LOGITS: bool = False + VLLM_RAISE_ON_LOGIT_NANS: bool = False + VLLM_ROCM_QUICK_REDUCE_QUANTIZATION: Literal[ + "FP", "INT8", "INT6", "INT4", "INT3", "NONE" + ] = "NONE" + VLLM_ROCM_QUICK_REDUCE_CAST_BF16_TO_FP16: bool = True + VLLM_ROCM_QUICK_REDUCE_MAX_SIZE_BYTES_MB: int | None = None + VLLM_ROCM_QUICK_REDUCE_MIN_SIZE_BYTES_MB: int | None = None + VLLM_ROCM_QUICK_REDUCE_QUANTIZATION_MIN_SIZE_KB: int | None = None + VLLM_MOONCAKE_ABORT_REQUEST_TIMEOUT: int = 480 + VLLM_MOONCAKE_CONNECTOR_TIMEOUT: float = 30.0 + VLLM_ENABLE_CUDAGRAPH_GC: bool = False + VLLM_LOOPBACK_IP: str = "" + VLLM_ALLOW_CHUNKED_LOCAL_ATTN_WITH_HYBRID_KV_CACHE: bool = True + VLLM_ENABLE_RESPONSES_API_STORE: bool = False + VLLM_ENABLE_COHERE_API: bool = False + VLLM_HAS_FLASHINFER_CUBIN: bool = False + VLLM_ROCM_FP8_MFMA_PAGE_ATTN: bool = False + VLLM_ALLREDUCE_USE_SYMM_MEM: bool = True + VLLM_ALLREDUCE_USE_FLASHINFER: bool = True + VLLM_ALLREDUCE_USE_FLASHINFER_PCIE_IPC: bool = False + VLLM_TUNED_CONFIG_FOLDER: str | None = None + VLLM_ENABLE_STARTUP_PLAN: bool = False + VLLM_GPT_OSS_SYSTEM_TOOL_MCP_LABELS: set[str] = set() + VLLM_USE_EXPERIMENTAL_PARSER_CONTEXT: bool = False + VLLM_GPT_OSS_HARMONY_SYSTEM_INSTRUCTIONS: bool = False + VLLM_SYSTEM_START_DATE: str | None = None + VLLM_TOOL_JSON_ERROR_AUTOMATIC_RETRY: bool = False + VLLM_CUSTOM_SCOPES_FOR_PROFILING: bool = False + VLLM_NVTX_SCOPES_FOR_PROFILING: bool = False + VLLM_KV_EVENTS_USE_INT_BLOCK_HASHES: bool = True + VLLM_OBJECT_STORAGE_SHM_BUFFER_NAME: str = "VLLM_OBJECT_STORAGE_SHM_BUFFER" + VLLM_DEEPEP_BUFFER_SIZE_MB: int = 1024 + VLLM_DEEPEP_HIGH_THROUGHPUT_FORCE_INTRA_NODE: bool = False + VLLM_DEEPEP_LOW_LATENCY_USE_MNNVL: bool = False + VLLM_DEEPEP_V2_ALLOW_HYBRID_MODE: bool = True + VLLM_DEEPEP_V2_PREFER_OVERLAP: bool = False + VLLM_DEEPEP_V2_ALLOW_MULTIPLE_REDUCTION: bool = False + VLLM_DBO_COMM_SMS: int = 20 + VLLM_PATTERN_MATCH_DEBUG: str | None = None + VLLM_DEBUG_DUMP_PATH: str | None = None + VLLM_ENABLE_INDUCTOR_MAX_AUTOTUNE: bool = True + VLLM_ENABLE_INDUCTOR_COORDINATE_DESCENT_TUNING: bool = True + VLLM_USE_NCCL_SYMM_MEM: bool = False + VLLM_NCCL_INCLUDE_PATH: str | None = None + VLLM_GC_DEBUG: str = "" + VLLM_DEBUG_WORKSPACE: bool = False + VLLM_DISABLE_SHARED_EXPERTS_STREAM: bool = False + VLLM_DISABLE_DSV4_MEGAMOE_SHARED_EXPERT_FUSION: bool = False + VLLM_SHARED_EXPERTS_STREAM_TOKEN_THRESHOLD: int = 256 + VLLM_MULTI_STREAM_GEMM_TOKEN_THRESHOLD: int = 1024 + VLLM_COMPILE_CACHE_SAVE_FORMAT: Literal["binary", "unpacked"] = "binary" + VLLM_USE_V2_MODEL_RUNNER: bool | None = None + VLLM_LOG_MODEL_INSPECTION: bool = False + VLLM_DEBUG_MFU_METRICS: bool = False + VLLM_WEIGHT_OFFLOADING_DISABLE_PIN_MEMORY: bool = False + VLLM_WEIGHT_OFFLOADING_DISABLE_UVA: bool = False + VLLM_KV_OFFLOAD_MAX_BATCH_DESCRIPTORS: int = 0 + VLLM_WSL2_ENABLE_PIN_MEMORY: bool = False + VLLM_DISABLE_LOG_LOGO: bool = False + VLLM_LORA_DISABLE_PDL: bool = False + VLLM_ENABLE_CUDA_COMPATIBILITY: bool = False + VLLM_CUDA_COMPATIBILITY_PATH: str | None = None + VLLM_SKIP_MODEL_NAME_VALIDATION: bool = False + """If set, vLLM will skip model name validation in API requests. + This allows any model name to be accepted in the 'model' field of requests, + making the server model-name agnostic. Useful for proxy/gateway scenarios.""" + VLLM_ELASTIC_EP_SCALE_UP_LAUNCH: bool = False + VLLM_ELASTIC_EP_DRAIN_REQUESTS: bool = False + VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS: bool = True + VLLM_XPU_FORCE_N_CONTIG_WEIGHT: bool = False + VLLM_XPU_USE_SAMPLER_KERNEL: bool = True + VLLM_XPU_INC_WNA16_BACKEND: Literal["auto", "ark", "w4a16", "w4a8"] = "auto" + VLLM_LORA_ENABLE_DUAL_STREAM: bool = False + VLLM_GPU_NIC_PCIE_MAPPING: str = "" + VLLM_NIC_SELECTION_VARS: str = "" + VLLM_ENABLE_HPC_OPS: bool = False + + +def get_default_cache_root(): + return os.getenv( + "XDG_CACHE_HOME", + os.path.join(os.path.expanduser("~"), ".cache"), + ) + + +def get_default_config_root(): + return os.getenv( + "XDG_CONFIG_HOME", + os.path.join(os.path.expanduser("~"), ".config"), + ) + + +def maybe_convert_int(value: str | None) -> int | None: + if value is None: + return None + return int(value) + + +def maybe_convert_bool(value: str | None) -> bool | None: + if value is None: + return None + return bool(int(value)) + + +def maybe_convert_json_str_or_file(value: str | None) -> dict[str, Any] | None: + if value is None: + return None + if os.path.exists(value): + with open(value) as f: + return json.load(f) + return json.loads(value) + + +def disable_compile_cache() -> bool: + return bool(int(os.getenv("VLLM_DISABLE_COMPILE_CACHE", "0"))) + + +def use_aot_compile() -> bool: + from vllm.utils.torch_utils import is_torch_equal_or_newer + + default_value = ( + "1" + if is_torch_equal_or_newer("2.10.0") and not disable_compile_cache() + else "0" + ) + + return os.environ.get("VLLM_USE_AOT_COMPILE", default_value) == "1" + + +def use_mega_aot_artifact(): + from vllm.utils.torch_utils import is_torch_equal_or_newer + + default_value = ( + "1" if is_torch_equal_or_newer("2.12.0.dev") and use_aot_compile() else "0" + ) + + return os.environ.get("VLLM_USE_MEGA_AOT_ARTIFACT", default_value) == "1" + + +def env_with_choices( + env_name: str, + default: str | None, + choices: list[str] | Callable[[], list[str]], + case_sensitive: bool = True, +) -> Callable[[], str | None]: + """Create a lambda that validates environment variable against allowed choices. + + Args: + env_name: Name of the environment variable + default: Default value if not set (can be None) + choices: List of valid string options or callable that returns list + case_sensitive: Whether validation should be case sensitive + + Returns: + Lambda function for environment_variables dict + + """ + + def _get_validated_env() -> str | None: + value = os.getenv(env_name) + if value is None: + return default + + # Resolve choices if it's a callable (for lazy loading) + actual_choices = choices() if callable(choices) else choices + + if not case_sensitive: + check_value = value.lower() + check_choices = [choice.lower() for choice in actual_choices] + else: + check_value = value + check_choices = actual_choices + + if check_value not in check_choices: + raise ValueError( + f"Invalid value '{value}' for {env_name}. " + f"Valid options: {actual_choices}." + ) + + return value + + return _get_validated_env + + +def env_list_with_choices( + env_name: str, + default: list[str], + choices: list[str] | Callable[[], list[str]], + case_sensitive: bool = True, +) -> Callable[[], list[str]]: + """Create a lambda that validates environment variable + containing comma-separated values against allowed choices + + Args: + env_name: Name of the environment variable + default: Default list of values if not set + choices: List of valid string options or callable that returns list + case_sensitive: Whether validation should be case sensitive + + Returns: + Lambda function for environment_variables + dict that returns list of strings + + """ + + def _get_validated_env_list() -> list[str]: + value = os.getenv(env_name) + if value is None: + return default + + # Split comma-separated values and strip whitespace + values = [v.strip() for v in value.split(",") if v.strip()] + + if not values: + return default + + # Resolve choices if it's a callable (for lazy loading) + actual_choices = choices() if callable(choices) else choices + + # Validate each value + for val in values: + if not case_sensitive: + check_value = val.lower() + check_choices = [choice.lower() for choice in actual_choices] + else: + check_value = val + check_choices = actual_choices + + if check_value not in check_choices: + raise ValueError( + f"Invalid value '{val}' in {env_name}. " + f"Valid options: {actual_choices}." + ) + + return values + + return _get_validated_env_list + + +def env_set_with_choices( + env_name: str, + default: list[str], + choices: list[str] | Callable[[], list[str]], + case_sensitive: bool = True, +) -> Callable[[], set[str]]: + """Creates a lambda which that validates environment variable + containing comma-separated values against allowed choices which + returns choices as a set. + """ + + def _get_validated_env_set() -> set[str]: + return set(env_list_with_choices(env_name, default, choices, case_sensitive)()) + + return _get_validated_env_set + + +def get_vllm_port() -> int | None: + """Get the port from VLLM_PORT environment variable. + + Returns: + The port number as an integer if VLLM_PORT is set, None otherwise. + + Raises: + ValueError: If VLLM_PORT is a URI, suggest k8s service discovery issue. + + """ + if "VLLM_PORT" not in os.environ: + return None + + port = os.getenv("VLLM_PORT", "0") + + try: + return int(port) + except ValueError as err: + from urllib3.util import parse_url + + parsed = parse_url(port) + if parsed.scheme: + raise ValueError( + f"VLLM_PORT '{port}' appears to be a URI. " + "This may be caused by a Kubernetes service discovery issue," + "check the warning in: https://docs.vllm.ai/en/latest/configuration/env_vars.html" + ) from None + raise ValueError(f"VLLM_PORT '{port}' must be a valid integer") from err + + +def _generate_shm_name() -> str: + import pybase64 + + # Fit macOS's 30-character limit without truncating the UUID. + encoded_uuid = pybase64.urlsafe_b64encode(uuid.uuid4().bytes).decode("ascii") + return "vllm_mm_" + encoded_uuid.rstrip("=") + + +def get_env_or_set_default( + env_name: str, + default_factory: Callable[[], str], +) -> Callable[[], str]: + """Create a lambda that returns an environment variable value if set, + or generates and sets a default value using the provided factory function. + """ + + def _get_or_set_default() -> str: + value = os.getenv(env_name) + if value is not None: + return value + + default_value = default_factory() + os.environ[env_name] = default_value + return default_value + + return _get_or_set_default + + +# The start-* and end* here are used by the documentation generator +# to extract the used env vars. + +# --8<-- [start:env-vars-definition] + +logger = logging.getLogger(__name__) + + +def _resolve_rust_cli_path() -> str | None: + """Resolve the vllm-rs binary path. + + Returns None unless VLLM_USE_RUST_FRONTEND or VLLM_USE_RUST_BENCH is enabled. + When enabled, resolves VLLM_RUST_FRONTEND_PATH ("auto" by default) + to the actual binary path. + """ + use_rust = bool(int(os.environ.get("VLLM_USE_RUST_FRONTEND", "0"))) or bool( + int(os.environ.get("VLLM_USE_RUST_BENCH", "0")) + ) + raw = os.environ.get("VLLM_RUST_FRONTEND_PATH", "auto") + + if not use_rust: + if os.environ.get("VLLM_RUST_FRONTEND_PATH") is not None: + logger.warning( + "VLLM_RUST_FRONTEND_PATH is set without enabling " + "VLLM_USE_RUST_FRONTEND or VLLM_USE_RUST_BENCH. " + "Set one of them to 1 to use the vllm-rs binary." + ) + return None + + if raw.lower() in ("auto", "1", "true"): + pkg_dir = os.path.dirname(os.path.abspath(__file__)) + candidate = os.path.join(pkg_dir, "vllm-rs") + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + + raise FileNotFoundError( + "VLLM_RUST_FRONTEND_PATH=auto but the vllm-rs binary was " + f"not found at {candidate}. " + "Build with setuptools-rust or set the path explicitly." + ) + return raw + + +environment_variables: dict[str, Callable[[], Any]] = { + # ================== Installation Time Env Vars ================== + # Target device of vLLM, supporting [cuda (by default), + # rocm, cpu] + "VLLM_TARGET_DEVICE": lambda: os.getenv("VLLM_TARGET_DEVICE", "cuda").lower(), + # Main CUDA version of vLLM. This follows PyTorch but can be overridden. + "VLLM_MAIN_CUDA_VERSION": lambda: ( + os.getenv("VLLM_MAIN_CUDA_VERSION", "").lower() or "13.0" + ), + # Controls PyTorch float32 matmul precision mode within vLLM workers. + # Valid options mirror torch.set_float32_matmul_precision + "VLLM_FLOAT32_MATMUL_PRECISION": env_with_choices( + "VLLM_FLOAT32_MATMUL_PRECISION", + "highest", + ["highest", "high", "medium"], + case_sensitive=False, + ), + # Enable batch-invariant mode: deterministic results regardless of + # batch composition. Requires NVIDIA GPU with compute capability >= 9.0. + "VLLM_BATCH_INVARIANT": lambda: bool(int(os.getenv("VLLM_BATCH_INVARIANT", "0"))), + "VLLM_REPLICATE_EMBED": lambda: ( + os.getenv("VLLM_REPLICATE_EMBED", "0").strip().lower() in ("1", "true") + ), + # Use tensor descriptors for Q/K/V loads and output stores in the + # Triton unified-attention kernel. Enables HW 2D block reads on + # Intel XPU; the non-TD branch is dead-code-eliminated at Triton + # compile time so other platforms see no overhead. Tri-state override: + # unset (default) lets the `triton_attn` backend auto-select per + # platform (currently auto-enabled on XPU only); ``1`` forces TD on; + # ``0`` forces TD off. Useful for A/B benchmarking the TD path. + "VLLM_TRITON_USE_TD": lambda: {"1": True, "0": False}.get( + os.getenv("VLLM_TRITON_USE_TD", "").strip() + ), + # If set, enable GPU<->CPU synchronization checking around the worker's + # `execute_model` and `sample_tokens` calls, via PyTorch's sync debug mode + # plus wrappers flagging `non_blocking` CPU<->CUDA copies that silently + # block the host (CPU tensors that are not pinned or not densely laid out). + # Valid values are "warn" (warn on each sync) or "error" (raise on sync). + # Unset disables the check. See `torch.cuda.set_sync_debug_mode`. + "VLLM_GPU_SYNC_CHECK": env_with_choices( + "VLLM_GPU_SYNC_CHECK", None, ["warn", "error"] + ), + # Maximum number of compilation jobs to run in parallel. + # By default this is the number of CPUs + "MAX_JOBS": lambda: os.getenv("MAX_JOBS", None), + # Number of threads to use for nvcc + # By default this is 1. + # If set, `MAX_JOBS` will be reduced to avoid oversubscribing the CPU. + "NVCC_THREADS": lambda: os.getenv("NVCC_THREADS", None), + # If set, vllm will use precompiled native binaries (*.so and vllm-rs). + "VLLM_USE_PRECOMPILED": lambda: ( + os.environ.get("VLLM_USE_PRECOMPILED", "").strip().lower() in ("1", "true") + or bool(os.environ.get("VLLM_PRECOMPILED_WHEEL_LOCATION")) + ), + # If set, vllm will use the precompiled Rust frontend binary (vllm-rs). + "VLLM_USE_PRECOMPILED_RUST": lambda: ( + os.environ.get("VLLM_USE_PRECOMPILED_RUST", "").strip().lower() in ("1", "true") + ), + # If set, skip adding +precompiled suffix to version string + "VLLM_SKIP_PRECOMPILED_VERSION_SUFFIX": lambda: bool( + int(os.environ.get("VLLM_SKIP_PRECOMPILED_VERSION_SUFFIX", "0")) + ), + # Used to mark that setup.py is running in a Docker build context, + # in order to force the use of precompiled binaries. + "VLLM_DOCKER_BUILD_CONTEXT": lambda: ( + os.environ.get("VLLM_DOCKER_BUILD_CONTEXT", "").strip().lower() in ("1", "true") + ), + # Build provenance metadata embedded in official vllm-openai images. + # Set via Docker ENV at image build time; informational only. + "VLLM_BUILD_COMMIT": lambda: os.environ.get("VLLM_BUILD_COMMIT", "unknown"), + "VLLM_BUILD_PIPELINE": lambda: os.environ.get("VLLM_BUILD_PIPELINE", "local"), + "VLLM_BUILD_URL": lambda: os.environ.get("VLLM_BUILD_URL", ""), + "VLLM_IMAGE_TAG": lambda: os.environ.get("VLLM_IMAGE_TAG", ""), + # CMake build type + # If not set, defaults to "Debug" or "RelWithDebInfo" + # Available options: "Debug", "Release", "RelWithDebInfo" + "CMAKE_BUILD_TYPE": env_with_choices( + "CMAKE_BUILD_TYPE", None, ["Debug", "Release", "RelWithDebInfo"] + ), + # If set, vllm will print verbose logs during installation + "VERBOSE": lambda: bool(int(os.getenv("VERBOSE", "0"))), + # Root directory for vLLM configuration files + # Defaults to `~/.config/vllm` unless `XDG_CONFIG_HOME` is set + # Note that this not only affects how vllm finds its configuration files + # during runtime, but also affects how vllm installs its configuration + # files during **installation**. + "VLLM_CONFIG_ROOT": lambda: os.path.expanduser( + os.getenv( + "VLLM_CONFIG_ROOT", + os.path.join(get_default_config_root(), "vllm"), + ) + ), + # ================== Runtime Env Vars ================== + # Root directory for vLLM cache files + # Defaults to `~/.cache/vllm` unless `XDG_CACHE_HOME` is set + "VLLM_CACHE_ROOT": lambda: os.path.expanduser( + os.getenv( + "VLLM_CACHE_ROOT", + os.path.join(get_default_cache_root(), "vllm"), + ) + ), + # used in distributed environment to determine the ip address + # of the current node, when the node has multiple network interfaces. + # If you are using multi-node inference, you should set this differently + # on each node. + "VLLM_HOST_IP": lambda: os.getenv("VLLM_HOST_IP", ""), + # used in distributed environment to manually set the communication port + # Note: if VLLM_PORT is set, and some code asks for multiple ports, the + # VLLM_PORT will be used as the first port, and the rest will be generated + # by incrementing the VLLM_PORT value. + "VLLM_PORT": get_vllm_port, + # path used for ipc when the frontend api server is running in + # multi-processing mode to communicate with the backend engine process. + "VLLM_RPC_BASE_PATH": lambda: os.getenv( + "VLLM_RPC_BASE_PATH", tempfile.gettempdir() + ), + # If true, will load models from ModelScope instead of Hugging Face Hub. + "VLLM_USE_MODELSCOPE": lambda: ( + os.environ.get("VLLM_USE_MODELSCOPE", "False").strip().lower() in ("1", "true") + ), + # If true, replace the Rust BPE backend that powers HF fast tokenizers + # with the `fastokens` (https://github.com/crusoecloud/fastokens) shim. + # Applies to any tokenizer mode that loads an HF fast tokenizer + # (`hf`, `deepseek_v32`, `deepseek_v4`, …). The `fastokens` + # Python package must be installed. + "VLLM_USE_FASTOKENS": lambda: bool(int(os.getenv("VLLM_USE_FASTOKENS", "0"))), + # Interval in seconds to log a warning message when the ring buffer is full + "VLLM_RINGBUFFER_WARNING_INTERVAL": lambda: int( + os.environ.get("VLLM_RINGBUFFER_WARNING_INTERVAL", "60") + ), + # path to cudatoolkit home directory, under which should be bin, include, + # and lib directories. + "CUDA_HOME": lambda: os.environ.get("CUDA_HOME", None), + # Path to the NCCL library file. It is needed because nccl>=2.19 brought + # by PyTorch contains a bug: https://github.com/NVIDIA/nccl/issues/1234 + "VLLM_NCCL_SO_PATH": lambda: os.environ.get("VLLM_NCCL_SO_PATH", None), + # when `VLLM_NCCL_SO_PATH` is not set, vllm will try to find the nccl + # library file in the locations specified by `LD_LIBRARY_PATH` + "LD_LIBRARY_PATH": lambda: os.environ.get("LD_LIBRARY_PATH", None), + # flag to control the chunk size (in MB) for sleeping memory allocations under ROCm + "VLLM_ROCM_SLEEP_MEM_CHUNK_SIZE": lambda: int( + os.environ.get("VLLM_ROCM_SLEEP_MEM_CHUNK_SIZE", "256") + ), + # Feature flag to enable/disable Inductor standalone compile. + # In torch <= 2.7 we ignore this flag; in torch >= 2.9 this is + # enabled by default. + "VLLM_USE_STANDALONE_COMPILE": lambda: ( + os.environ.get("VLLM_USE_STANDALONE_COMPILE", "1") == "1" + ), + # Inductor's pre-grad passes don't do anything for vLLM. + # The pre-grad passes get run even on cache-hit and negatively impact + # vllm cold compile times by O(1s) + # Can remove this after the following issue gets fixed + # TODO(luka): maybe_inplace requires this + # https://github.com/pytorch/pytorch/issues/174502 + "VLLM_ENABLE_PREGRAD_PASSES": lambda: ( + os.environ.get("VLLM_ENABLE_PREGRAD_PASSES", "1") == "1" + ), + # Experimental: breakable cudagraph does not rely on torch.compile + "VLLM_USE_BREAKABLE_CUDAGRAPH": lambda: ( + os.environ.get("VLLM_USE_BREAKABLE_CUDAGRAPH", "0") == "1" + ), + # Debug pattern matching inside custom passes. + # Should be set to the fx.Node name (e.g. 'getitem_34' or 'scaled_mm_3'). + "VLLM_PATTERN_MATCH_DEBUG": lambda: os.environ.get( + "VLLM_PATTERN_MATCH_DEBUG", None + ), + # Dump fx graphs to the given directory. + # It will override CompilationConfig.debug_dump_path if set. + "VLLM_DEBUG_DUMP_PATH": lambda: os.environ.get("VLLM_DEBUG_DUMP_PATH", None), + # Feature flag to enable/disable AOT compilation. This will ensure + # compilation is done in warmup phase and the compilation will be + # reused in subsequent calls. + "VLLM_USE_AOT_COMPILE": use_aot_compile, + # Feature flag to enable/disable bytecode in + # TorchCompileWithNoGuardsWrapper. + "VLLM_USE_BYTECODE_HOOK": lambda: bool( + int(os.environ.get("VLLM_USE_BYTECODE_HOOK", "1")) + ), + # Force vllm to always load AOT compiled models from disk. Failure + # to load will result in a hard error when this is enabled. + # Will be ignored when VLLM_USE_AOT_COMPILE is disabled. + "VLLM_FORCE_AOT_LOAD": lambda: os.environ.get("VLLM_FORCE_AOT_LOAD", "0") == "1", + # Enable loading compiled models directly from cached standalone compile artifacts + # without re-splitting graph modules. This reduces overhead during model + # loading by using reconstruct_serializable_fn_from_mega_artifact. + "VLLM_USE_MEGA_AOT_ARTIFACT": use_mega_aot_artifact, + # local rank of the process in the distributed setting, used to determine + # the GPU device id + "LOCAL_RANK": lambda: int(os.environ.get("LOCAL_RANK", "0")), + # used to control the visible devices in the distributed setting + "CUDA_VISIBLE_DEVICES": lambda: os.environ.get("CUDA_VISIBLE_DEVICES", None), + # Timeout in seconds for waiting for engine cores to become ready + # during startup. Default is 600 seconds (10 minutes). + "VLLM_ENGINE_READY_TIMEOUT_S": lambda: int( + os.environ.get("VLLM_ENGINE_READY_TIMEOUT_S", "600") + ), + # Maximum wall-clock seconds allowed for a single chat template render. + # Set to 0 to disable the timeout. + "VLLM_CHAT_TEMPLATE_RENDER_TIMEOUT": lambda: float( + os.environ.get("VLLM_CHAT_TEMPLATE_RENDER_TIMEOUT", "30") + ), + # API key for vLLM API server + "VLLM_API_KEY": lambda: os.environ.get("VLLM_API_KEY", None), + # Whether to log responses from API Server for debugging + "VLLM_DEBUG_LOG_API_SERVER_RESPONSE": lambda: ( + os.environ.get("VLLM_DEBUG_LOG_API_SERVER_RESPONSE", "False").lower() == "true" + ), + # S3 access information, used for tensorizer to load model from S3 + "S3_ACCESS_KEY_ID": lambda: os.environ.get("S3_ACCESS_KEY_ID", None), + "S3_SECRET_ACCESS_KEY": lambda: os.environ.get("S3_SECRET_ACCESS_KEY", None), + "S3_ENDPOINT_URL": lambda: os.environ.get("S3_ENDPOINT_URL", None), + # Usage stats collection + "VLLM_USAGE_STATS_SERVER": lambda: os.environ.get( + "VLLM_USAGE_STATS_SERVER", "https://stats.vllm.ai" + ), + "VLLM_NO_USAGE_STATS": lambda: os.environ.get("VLLM_NO_USAGE_STATS", "0") == "1", + "VLLM_DO_NOT_TRACK": lambda: ( + ( + os.environ.get("VLLM_DO_NOT_TRACK", None) + or os.environ.get("DO_NOT_TRACK", None) + or "0" + ) + == "1" + ), + "VLLM_USAGE_SOURCE": lambda: os.environ.get("VLLM_USAGE_SOURCE", "production"), + # Logging configuration + # If set to 0, vllm will not configure logging + # If set to 1, vllm will configure logging using the default configuration + # or the configuration file specified by VLLM_LOGGING_CONFIG_PATH + "VLLM_CONFIGURE_LOGGING": lambda: bool( + int(os.getenv("VLLM_CONFIGURE_LOGGING", "1")) + ), + "VLLM_LOGGING_CONFIG_PATH": lambda: os.getenv("VLLM_LOGGING_CONFIG_PATH"), + # this is used for configuring the default logging level + "VLLM_LOGGING_LEVEL": lambda: os.getenv("VLLM_LOGGING_LEVEL", "INFO").upper(), + # this is used for configuring the default logging stream + "VLLM_LOGGING_STREAM": lambda: os.getenv("VLLM_LOGGING_STREAM", "ext://sys.stdout"), + # if set, VLLM_LOGGING_PREFIX will be prepended to all log messages + "VLLM_LOGGING_PREFIX": lambda: os.getenv("VLLM_LOGGING_PREFIX", ""), + # Controls colored logging output. Options: "auto" (default, colors when terminal), + # "1" (always use colors), "0" (never use colors) + "VLLM_LOGGING_COLOR": lambda: os.getenv("VLLM_LOGGING_COLOR", "auto"), + # Standard unix flag for disabling ANSI color codes + "NO_COLOR": lambda: os.getenv("NO_COLOR", "0") != "0", + # De-facto standard flag for forcing ANSI color codes (e.g. non-tty case) + "FORCE_COLOR": lambda: os.getenv("FORCE_COLOR", "0") != "0", + # If set, vllm will log stats at this interval in seconds + # If not set, vllm will log stats every 10 seconds. + "VLLM_LOG_STATS_INTERVAL": lambda: ( + val + if (val := float(os.getenv("VLLM_LOG_STATS_INTERVAL", "10."))) > 0.0 + else 10.0 + ), + # Trace function calls + # If set to 1, vllm will trace function calls + # Useful for debugging + "VLLM_TRACE_FUNCTION": lambda: int(os.getenv("VLLM_TRACE_FUNCTION", "0")), + # Whether to use the FlashInfer top-k / top-p sampler on CUDA. Enabled + # by default when the hardware supports it — set to 0 to opt out + # explicitly, which forces the PyTorch-native (Triton for bs>=8) path. + "VLLM_USE_FLASHINFER_SAMPLER": lambda: ( + bool(int(os.environ["VLLM_USE_FLASHINFER_SAMPLER"])) + if "VLLM_USE_FLASHINFER_SAMPLER" in os.environ + else True + ), + # Pipeline stage partition strategy + "VLLM_PP_LAYER_PARTITION": lambda: os.getenv("VLLM_PP_LAYER_PARTITION", None), + # (CPU backend only) CPU key-value cache space. + # default is None and will be set as 4 GB + "VLLM_CPU_KVCACHE_SPACE": lambda: ( + int(os.getenv("VLLM_CPU_KVCACHE_SPACE", "0")) + if "VLLM_CPU_KVCACHE_SPACE" in os.environ + else None + ), + # (CPU backend only) CPU core ids bound by OpenMP threads, e.g., "0-31", + # "0,1,2", "0-31,33". CPU cores of different ranks are separated by '|'. + "VLLM_CPU_OMP_THREADS_BIND": lambda: os.getenv("VLLM_CPU_OMP_THREADS_BIND", "auto"), + # (CPU backend only) CPU cores not used by OMP threads . + # Those CPU cores will not be used by OMP threads of a rank. + "VLLM_CPU_NUM_OF_RESERVED_CPU": lambda: ( + int(os.getenv("VLLM_CPU_NUM_OF_RESERVED_CPU", "0")) + if "VLLM_CPU_NUM_OF_RESERVED_CPU" in os.environ + else None + ), + # (CPU backend only) whether vLLM is running in a CI environment. + "VLLM_CPU_CI_ENV": lambda: bool(int(os.getenv("VLLM_CPU_CI_ENV", "0"))), + # (CPU backend only) whether to enable attention spilt KV. + "VLLM_CPU_ATTN_SPLIT_KV": lambda: bool( + int(os.getenv("VLLM_CPU_ATTN_SPLIT_KV", "1")) + ), + # (Zen CPU backend) eagerly prepack weights into ZenDNN blocked layout + # at model load time. Eliminates per-inference layout conversion overhead. + "VLLM_ZENTORCH_WEIGHT_PREPACK": lambda: bool( + int(os.getenv("VLLM_ZENTORCH_WEIGHT_PREPACK", "1")) + ), + # (CPU backend only) whether to use SGLang INT4 W4A8 kernels for AWQ, and + # on Zen CPUs whether to serve int4 checkpoints as DA8W4 rather than W4A16. + "VLLM_CPU_INT4_W4A8": lambda: bool(int(os.getenv("VLLM_CPU_INT4_W4A8", "1"))), + # If the env var is set, Ray Compiled Graph uses the specified + # channel type to communicate between workers belonging to + # different pipeline-parallel stages. + # Available options: + # - "auto": use the default channel type + # - "nccl": use NCCL for communication + # - "shm": use shared memory and gRPC for communication + "VLLM_USE_RAY_COMPILED_DAG_CHANNEL_TYPE": env_with_choices( + "VLLM_USE_RAY_COMPILED_DAG_CHANNEL_TYPE", "auto", ["auto", "nccl", "shm"] + ), + # If the env var is set, it enables GPU communication overlap + # (experimental feature) in Ray's Compiled Graph. + "VLLM_USE_RAY_COMPILED_DAG_OVERLAP_COMM": lambda: bool( + int(os.getenv("VLLM_USE_RAY_COMPILED_DAG_OVERLAP_COMM", "0")) + ), + # If the env var is set, it uses a Ray Communicator wrapping + # vLLM's pipeline parallelism communicator to interact with Ray's + # Compiled Graph. Otherwise, it uses Ray's NCCL communicator. + "VLLM_USE_RAY_WRAPPED_PP_COMM": lambda: bool( + int(os.getenv("VLLM_USE_RAY_WRAPPED_PP_COMM", "1")) + ), + # Using a flag to control microbatch on XPU device, users on XPU device can + # decide to disable it when they needed. + "VLLM_XPU_PP_MICROBATCH": lambda: bool( + int(os.getenv("VLLM_XPU_PP_MICROBATCH", "1")) + ), + # When True and distributed_executor_backend="ray", use RayExecutorV2 + # (MQ-based) instead of RayDistributedExecutor (compiled-graph backend). + "VLLM_USE_RAY_V2_EXECUTOR_BACKEND": lambda: bool( + int(os.getenv("VLLM_USE_RAY_V2_EXECUTOR_BACKEND", "1")) + ), + # When True, GroupCoordinator constructs its CPU/device subgroups via + # ``torch.distributed.split_group(backend=...)`` + # and ``init_distributed_environment`` initializes the default PG with + # mixed ``cpu:gloo,cuda:nccl`` backend + eager ``device_id`` binding. + "VLLM_DISTRIBUTED_USE_SPLIT_GROUP": lambda: bool( + int(os.getenv("VLLM_DISTRIBUTED_USE_SPLIT_GROUP", "0")) + ), + # Use dedicated multiprocess context for workers. + # Both spawn and fork work + "VLLM_WORKER_MULTIPROC_METHOD": env_with_choices( + "VLLM_WORKER_MULTIPROC_METHOD", "fork", ["spawn", "fork"] + ), + # Path to the cache for storing downloaded assets + "VLLM_ASSETS_CACHE": lambda: os.path.expanduser( + os.getenv( + "VLLM_ASSETS_CACHE", + os.path.join(get_default_cache_root(), "vllm", "assets"), + ) + ), + # If the env var is set, we will clean model file in + # this path $VLLM_ASSETS_CACHE/model_streamer/$model_name + "VLLM_ASSETS_CACHE_MODEL_CLEAN": lambda: bool( + int(os.getenv("VLLM_ASSETS_CACHE_MODEL_CLEAN", "0")) + ), + # Timeout for fetching images when serving multimodal models + # Default is 5 seconds + "VLLM_IMAGE_FETCH_TIMEOUT": lambda: int(os.getenv("VLLM_IMAGE_FETCH_TIMEOUT", "5")), + # Timeout for fetching videos when serving multimodal models + # Default is 30 seconds + "VLLM_VIDEO_FETCH_TIMEOUT": lambda: int( + os.getenv("VLLM_VIDEO_FETCH_TIMEOUT", "30") + ), + # Timeout for fetching audio when serving multimodal models + # Default is 10 seconds + "VLLM_AUDIO_FETCH_TIMEOUT": lambda: int( + os.getenv("VLLM_AUDIO_FETCH_TIMEOUT", "10") + ), + # Directory for caching media downloads (images, video, audio fetched + # from URLs during inference). Empty string disables caching. + "VLLM_MEDIA_CACHE": lambda: os.getenv("VLLM_MEDIA_CACHE", ""), + # Maximum cache size in MB. When exceeded, least-recently-used entries + # are evicted. Default is 5120 (5 GB). + "VLLM_MEDIA_CACHE_MAX_SIZE_MB": lambda: int( + os.getenv("VLLM_MEDIA_CACHE_MAX_SIZE_MB", "5120") + ), + # Time-to-live in hours for cached media files. Entries older than this + # are evicted regardless of cache size. Default is 24 hours. + "VLLM_MEDIA_CACHE_TTL_HOURS": lambda: float( + os.getenv("VLLM_MEDIA_CACHE_TTL_HOURS", "24") + ), + # Maximum number of retries for fetching media (images, audio, video) + # from URLs. Each retry quadruples the timeout. Default is 3. + "VLLM_MEDIA_FETCH_MAX_RETRIES": lambda: int( + os.getenv("VLLM_MEDIA_FETCH_MAX_RETRIES", "3") + ), + # Maximum size in MB for a single remote media download. The limit is + # enforced while streaming the response body so oversized or infinite + # responses cannot grow the API server heap without bound. Default is 256. + "VLLM_MAX_MEDIA_DOWNLOAD_SIZE_MB": lambda: int( + os.getenv("VLLM_MAX_MEDIA_DOWNLOAD_SIZE_MB", "256") + ), + # Whether to allow HTTP redirects when fetching from media URLs. + # Default to True + "VLLM_MEDIA_URL_ALLOW_REDIRECTS": lambda: bool( + int(os.getenv("VLLM_MEDIA_URL_ALLOW_REDIRECTS", "1")) + ), + # Max number of workers for the thread pool handling + # media bytes loading. Set to 1 to disable parallel processing. + # Default is 8 + "VLLM_MEDIA_LOADING_THREAD_COUNT": lambda: int( + os.getenv("VLLM_MEDIA_LOADING_THREAD_COUNT", "8") + ), + # Maximum filesize in MB for a single audio file. Enforced on all + # audio inputs (multimodal chat, speech-to-text uploads, data: URLs, + # and local file:// paths). Files larger than this will be rejected + # before decoding. Default is 25 MB. + "VLLM_MAX_AUDIO_CLIP_FILESIZE_MB": lambda: int( + os.getenv("VLLM_MAX_AUDIO_CLIP_FILESIZE_MB", "25") + ), + # Maximum decoded audio duration in seconds. Compressed audio files + # (e.g. OPUS at very low bitrate) can expand into gigabytes of float32 + # PCM. This limit is enforced *during* decoding so the memory is never + # allocated. Default is 600s (10 minutes). + "VLLM_MAX_AUDIO_DECODE_DURATION_S": lambda: int( + os.getenv("VLLM_MAX_AUDIO_DECODE_DURATION_S", "600") + ), + # Maximum float32 PCM bytes that audio decoding may allocate. + # Guards against sample-rate forgery where an attacker inflates + # the header sample rate to bypass the duration guard while the + # actual frame count still causes a multi-GiB allocation. + # Default is 256 MiB (sufficient for 600s mono 48 kHz float32). + "VLLM_MAX_AUDIO_DECODE_BYTES": lambda: int( + os.getenv("VLLM_MAX_AUDIO_DECODE_BYTES", "268435456") + ), + # Maximum bytes a client-supplied embedding payload (`prompt_embeds`, + # `image_embeds`, `audio_embeds`, `video_embeds`) may allocate once it is + # densified. A sparse tensor carries its own declared shape, so a payload + # of a few hundred bytes can expand into hundreds of GiB. The limit is + # checked before `to_dense()` so the memory is never allocated. Set to 0 + # to disable. Default is 2 GiB, which covers a 128K-token float32 + # embedding at hidden_size 4096. + "VLLM_MAX_EMBED_DECODE_BYTES": lambda: int( + os.getenv("VLLM_MAX_EMBED_DECODE_BYTES", "2147483648") + ), + # Maximum number of worker threads used for STT preprocessing. The default + # intentionally caps at 2 because that performed best in profiling. + # https://github.com/vllm-project/vllm/pull/44612#issuecomment-4662757781 + "VLLM_MAX_AUDIO_PREPROCESS_WORKERS": lambda: int( + os.getenv( + "VLLM_MAX_AUDIO_PREPROCESS_WORKERS", + str(max(1, min(os.cpu_count() or 1, 2))), + ) + ), + # Maximum decoded image size in pixels. Small compressed images can + # expand into gigabytes of raster memory. This limit is enforced before + # decoding so the memory is never allocated. Default matches PIL's + # built-in 2x decompression-bomb threshold (~179M pixels, ~680 MB RGB). + "VLLM_MAX_IMAGE_PIXELS": lambda: int( + os.getenv("VLLM_MAX_IMAGE_PIXELS", "178956970") + ), + # Backend for Video IO — selects the frame-sampling algorithm. + # - "opencv": uniform sampling. + # - "opencv_dynamic": duration-aware dynamic sampling. + # + # Custom backend implementations can be registered + # via `@VIDEO_LOADER_REGISTRY.register("my_custom_video_loader")` and + # imported at runtime. + # If a non-existing backend is used, an AssertionError will be thrown. + "VLLM_VIDEO_LOADER_BACKEND": lambda: os.getenv( + "VLLM_VIDEO_LOADER_BACKEND", "opencv" + ), + # Media connector implementation. + # - "http": Default connector that supports fetching media via HTTP. + # + # Custom implementations can be registered + # via `@MEDIA_CONNECTOR_REGISTRY.register("my_custom_media_connector")` and + # imported at runtime. + # If a non-existing backend is used, an AssertionError will be thrown. + "VLLM_MEDIA_CONNECTOR": lambda: os.getenv("VLLM_MEDIA_CONNECTOR", "http"), + # Path to the XLA persistent cache directory. + # Only used for XLA devices such as TPUs. + "VLLM_XLA_CACHE_PATH": lambda: os.path.expanduser( + os.getenv( + "VLLM_XLA_CACHE_PATH", + os.path.join(get_default_cache_root(), "vllm", "xla_cache"), + ) + ), + # If set, assert on XLA recompilation after each execution step. + "VLLM_XLA_CHECK_RECOMPILATION": lambda: bool( + int(os.getenv("VLLM_XLA_CHECK_RECOMPILATION", "0")) + ), + # Enable SPMD mode for TPU backend. + "VLLM_XLA_USE_SPMD": lambda: bool(int(os.getenv("VLLM_XLA_USE_SPMD", "0"))), + # Maximum size (in MB) for logits tensor in sparse MLA indexer prefill chunks. + # Bounds the [M, N] float32 logits tensor to prevent CUDA OOM. + # Default: 512 MB + "VLLM_SPARSE_INDEXER_MAX_LOGITS_MB": lambda: int( + os.getenv("VLLM_SPARSE_INDEXER_MAX_LOGITS_MB", "512") + ), + # KV context length each adaptive-verification profiling request pretends to + # carry, so the profiled step reads a realistic amount of cache. + # Raise it for long-context deployments, where step cost is dominated by + # attention over a much larger KV cache than the default assumes. + # Clamped to max_model_len - query_len. Default: 8192 tokens + "VLLM_ADAPTIVE_VERIFICATION_PROFILE_CONTEXT_LEN": lambda: int( + os.getenv("VLLM_ADAPTIVE_VERIFICATION_PROFILE_CONTEXT_LEN", "8192") + ), + # If set, the OpenAI API server will stay alive even after the underlying + # AsyncLLMEngine errors and stops serving requests + "VLLM_KEEP_ALIVE_ON_ENGINE_DEATH": lambda: bool( + int(os.getenv("VLLM_KEEP_ALIVE_ON_ENGINE_DEATH", "0")) + ), + # If the env var VLLM_ALLOW_LONG_MAX_MODEL_LEN is set, it allows + # the user to specify a max sequence length greater than + # the max length derived from the model's config.json. + # To enable this, set VLLM_ALLOW_LONG_MAX_MODEL_LEN=1. + "VLLM_ALLOW_LONG_MAX_MODEL_LEN": lambda: ( + os.environ.get("VLLM_ALLOW_LONG_MAX_MODEL_LEN", "0").strip().lower() + in ("1", "true") + ), + "VLLM_TEST_FORCE_LOAD_FORMAT": lambda: os.getenv( + "VLLM_TEST_FORCE_LOAD_FORMAT", "dummy" + ), + # Queue size for fastsafetensors ParallelLoader pipelined weight + # loading. Peak load-time VRAM is roughly + # model_weights + (1 + queue_size) * shard_size. + # Default 0 preserves the non-pipelined memory footprint so this + # change does not shrink the loadable-model envelope. Set to 1 + # (or higher) to overlap producing the next shard's device buffer + # with the consumer copying the current shard into model params, + # at the cost of `queue_size` extra shard-sized buffers resident + # at peak during loading. + "VLLM_FASTSAFETENSORS_QUEUE_SIZE": lambda: int( + os.getenv("VLLM_FASTSAFETENSORS_QUEUE_SIZE", "0") + ), + # Timeout in seconds for keeping HTTP connections alive in API server + "VLLM_HTTP_TIMEOUT_KEEP_ALIVE": lambda: int( + os.environ.get("VLLM_HTTP_TIMEOUT_KEEP_ALIVE", "5") + ), + # Maximum allowed value for the `n` sampling parameter (number of output + # sequences per request). Limits resource consumption to prevent + # denial-of-service via excessively large fan-out. Default: 16384. + "VLLM_MAX_N_SEQUENCES": lambda: int( + os.environ.get("VLLM_MAX_N_SEQUENCES", "16384") + ), + # Maximum number of prompts allowed in a single /v1/completions request + # when the prompt field is a list. Prevents unbounded fan-out of engine + # requests from a single API call. Default: 1024. + "VLLM_MAX_COMPLETION_PROMPTS": lambda: int( + os.environ.get("VLLM_MAX_COMPLETION_PROMPTS", "1024") + ), + # Maximum number of stop strings allowed in a single request. + "VLLM_MAX_STOP_STRINGS": lambda: int(os.environ.get("VLLM_MAX_STOP_STRINGS", "4")), + # Maximum number of bad-word token sequences generated per request. + "VLLM_MAX_NUM_BAD_WORDS": lambda: int( + os.environ.get("VLLM_MAX_NUM_BAD_WORDS", "128") + ), + # Maximum total number of bad-word tokens (summed across all bad words) + # allowed per request. Bounds the per-request GPU buffer width. + "VLLM_MAX_BAD_WORDS_TOTAL_TOKENS": lambda: int( + os.environ.get("VLLM_MAX_BAD_WORDS_TOTAL_TOKENS", "1024") + ), + # a list of plugin names to load, separated by commas. + # if this is not set, it means all plugins will be loaded + # if this is set to an empty string, no plugins will be loaded + "VLLM_PLUGINS": lambda: ( + None + if "VLLM_PLUGINS" not in os.environ + else os.environ["VLLM_PLUGINS"].split(",") + ), + # a local directory to look in for unrecognized LoRA adapters. + # only works if plugins are enabled and + # VLLM_ALLOW_RUNTIME_LORA_UPDATING is enabled. + "VLLM_LORA_RESOLVER_CACHE_DIR": lambda: os.getenv( + "VLLM_LORA_RESOLVER_CACHE_DIR", None + ), + # A remote HF repo(s) containing one or more LoRA adapters, which + # may be downloaded and leveraged as needed. Only works if plugins + # are enabled and VLLM_ALLOW_RUNTIME_LORA_UPDATING is enabled. + # Values should be comma separated. + "VLLM_LORA_RESOLVER_HF_REPO_LIST": lambda: os.getenv( + "VLLM_LORA_RESOLVER_HF_REPO_LIST", None + ), + # If set, vLLM will use Triton implementations of AWQ. + "VLLM_USE_TRITON_AWQ": lambda: bool(int(os.getenv("VLLM_USE_TRITON_AWQ", "0"))), + # If set, monkey-patch triton.runtime.autotuner.Autotuner.run to skip + # benchmarking and select the first valid config (walking past invalid + # ones). Used to eliminate autotuning variability when measuring kernel + # performance and applied before running any kernel. + "VLLM_TRITON_FORCE_FIRST_CONFIG": lambda: ( + os.environ.get("VLLM_TRITON_FORCE_FIRST_CONFIG", "0").strip().lower() + in ("1", "true") + ), + # Maximum compiler threads per worker for registered CUDA Triton JIT warmup. + # Set to 1 for serial warmup. Does not affect runtime JIT or autotuning. + "VLLM_TRITON_JIT_WARMUP_NUM_THREADS": lambda: int( + os.getenv("VLLM_TRITON_JIT_WARMUP_NUM_THREADS", "4") + ), + # If set, allow loading or unloading lora adapters in runtime, + "VLLM_ALLOW_RUNTIME_LORA_UPDATING": lambda: ( + os.environ.get("VLLM_ALLOW_RUNTIME_LORA_UPDATING", "0").strip().lower() + in ("1", "true") + ), + # We assume drivers can report p2p status correctly. + # If the program hangs when using custom allreduce, + # potantially caused by a bug in the driver (535 series), + # if might be helpful to set VLLM_SKIP_P2P_CHECK=0 + # so that vLLM can verify if p2p is actually working. + # See https://github.com/vllm-project/vllm/blob/a9b15c606fea67a072416ea0ea115261a2756058/vllm/distributed/device_communicators/custom_all_reduce_utils.py#L101-L108 for details. # noqa + "VLLM_SKIP_P2P_CHECK": lambda: os.getenv("VLLM_SKIP_P2P_CHECK", "1") == "1", + # List of quantization kernels that should be disabled, used for testing + # and performance comparisons. Currently only affects MPLinearKernel + # selection + # (kernels: MacheteLinearKernel, MarlinLinearKernel, ExllamaLinearKernel) + "VLLM_DISABLED_KERNELS": lambda: ( + [] + if "VLLM_DISABLED_KERNELS" not in os.environ + else os.environ["VLLM_DISABLED_KERNELS"].split(",") + ), + # Selects hw-agnostic layers for HF transformer backend + "VLLM_USE_HW_AGNOSTIC": lambda: ( + os.getenv("VLLM_USE_HW_AGNOSTIC", "False").lower() in ("true", "1") + ), + "VLLM_ENABLE_FLA_PACKED_RECURRENT_DECODE": lambda: bool( + int(os.getenv("VLLM_ENABLE_FLA_PACKED_RECURRENT_DECODE", "1")) + ), + # Select the GDN MTP decode implementation. "cuda" uses the fused decode + # kernel where supported and falls back to "triton" otherwise; setting it + # explicitly to "cuda" raises when unsupported. + "VLLM_GDN_DECODE_KERNEL": env_with_choices( + "VLLM_GDN_DECODE_KERNEL", + "cuda", + ["cuda", "triton"], + case_sensitive=False, + ), + # Disable pynccl (using torch.distributed instead) + "VLLM_DISABLE_PYNCCL": lambda: ( + os.getenv("VLLM_DISABLE_PYNCCL", "False").lower() in ("true", "1") + ), + # Optional: enable external Oink custom ops (e.g., Blackwell RMSNorm). + # Disabled by default. + "VLLM_USE_OINK_OPS": lambda: ( + os.getenv("VLLM_USE_OINK_OPS", "False").lower() in ("true", "1") + ), + # Disable aiter ops unless specifically enabled. + # Acts as a parent switch to enable the rest of the other operations. + # Set to 1 to dequantize MXFP4 weights to BF16 once at load time and run as + # a BF16 checkpoint (no per-step weight dequant). This improves emulation + # latency at the cost of additional device memory. Default off. + "VLLM_MXFP4_EMULATION_DEQUANT_AT_LOAD": lambda: ( + os.getenv("VLLM_MXFP4_EMULATION_DEQUANT_AT_LOAD", "False").lower() + in ("true", "1") + ), + # On hardware without a native MXFP8 kernel (e.g. ROCm gfx942 / MI300), the + # MXFP8 emulation path dequantizes weights MXFP8->BF16 once at load time and + # runs as a BF16 checkpoint (no per-step dequant). Set to 0 to fall back to + # per-step dequant: keeps the 1-byte MXFP8 weights (~half the weight memory) + # at the cost of dequantizing every forward step (much slower). Default on. + "VLLM_MXFP8_EMULATION_DEQUANT_AT_LOAD": lambda: ( + os.getenv("VLLM_MXFP8_EMULATION_DEQUANT_AT_LOAD", "True").lower() + in ("true", "1") + ), + "VLLM_ROCM_USE_AITER": lambda: ( + os.getenv("VLLM_ROCM_USE_AITER", "False").lower() in ("true", "1") + ), + # Use AITER's CustomAllreduce as the custom-allreduce backend inside vLLM's + # CudaCommunicator on ROCm. Also enables AITER AG/RS for DP communication. + "VLLM_ROCM_USE_AITER_CUSTOM_AR": lambda: ( + os.getenv("VLLM_ROCM_USE_AITER_CUSTOM_AR", "True").lower() in ("true", "1") + ), + # use aiter linear op if aiter ops are enabled + # The following list of related ops + # - scaled_mm (per-tensor / rowwise) + # - use aiter tuned gemms for unquantized gemms + "VLLM_ROCM_USE_AITER_LINEAR": lambda: ( + os.getenv("VLLM_ROCM_USE_AITER_LINEAR", "True").lower() in ("true", "1") + ), + "VLLM_ROCM_USE_AITER_LINEAR_HIPBMM": lambda: ( + os.getenv("VLLM_ROCM_USE_AITER_LINEAR_HIPBMM", "False").lower() in ("true", "1") + ), + # Whether to use aiter moe ops. + # By default is enabled. + "VLLM_ROCM_USE_AITER_MOE": lambda: ( + os.getenv("VLLM_ROCM_USE_AITER_MOE", "True").lower() in ("true", "1") + ), + # Activation dtype for the Kimi-K3 SiTU MXFP4 MoE (AITER FlyDSL SiTUv2): + # auto (= a4w4), a4w4, a8w4 or a16w4. Legacy 1/0 mean a4w4/a16w4. + "VLLM_ROCM_USE_AITER_MOE_SITUV2": env_with_choices( + "VLLM_ROCM_USE_AITER_MOE_SITUV2", + "auto", + ["auto", "a4w4", "a8w4", "a16w4", "0", "1"], + case_sensitive=False, + ), + # Opt-in switch for a4w4 (FP4 activation) MoE on DeepSeek V4.1, AITER + # MXFP4 backend. Default is a8w4 (FP8); set to "1" to enable a4w4 + # ("true" is not accepted -- only "0"/"1"). Raises if set for other + # models. + "VLLM_ROCM_USE_AITER_MOE_A4W4_DSV4": lambda: maybe_convert_bool( + os.getenv("VLLM_ROCM_USE_AITER_MOE_A4W4_DSV4") + ), + # MoE sorting dispatch policy for AITER fused MoE kernels. + # 0 = auto (default): single-pass for small batches, multi-pass + # for large batches + # 1 = always single-pass: one kernel launch, no workspace, + # may be preferred for low-concurrency decode workloads + # 2 = always multi-pass: can be faster for MoE-heavy models + # (e.g., +2-5% on Qwen3-Next, +1.5% on DeepSeek-V3 at TP4, + # see PR #39177 for benchmarks) + "VLLM_ROCM_AITER_MOE_DISPATCH_POLICY": lambda: int( + os.getenv("VLLM_ROCM_AITER_MOE_DISPATCH_POLICY", "0") + ), + # use aiter rms norm op if aiter ops are enabled. + "VLLM_ROCM_USE_AITER_RMSNORM": lambda: ( + os.getenv("VLLM_ROCM_USE_AITER_RMSNORM", "True").lower() in ("true", "1") + ), + # Whether to use aiter mla ops. + # By default is enabled. + "VLLM_ROCM_USE_AITER_MLA": lambda: ( + os.getenv("VLLM_ROCM_USE_AITER_MLA", "True").lower() in ("true", "1") + ), + # Kernel for causal multi-token (spec-decode) verify steps under decode + # context parallelism: "asm" uses AITER's round-robin ASM decode (gfx950), + # "segmented" the Triton segmented MLA path. "auto" (default) takes "asm" + # wherever it can serve the shape and "segmented" otherwise. + "VLLM_ROCM_AITER_MLA_DCP_VERIFY": env_with_choices( + "VLLM_ROCM_AITER_MLA_DCP_VERIFY", + "auto", + ["auto", "asm", "segmented"], + case_sensitive=False, + ), + # Small-head (<16) AITER MLA decode kernel selection. Small head counts + # (e.g. Kimi-K3: 12 heads/rank at TP8, 6 at TP16) can decode either through + # the Gluon small-head kernel or through the padded persistent-scheduling + # (PS) ASM kernel. "auto" (default) keeps Gluon for head counts that divide + # 16 where a Gluon build exists (gfx950/CDNA4) and otherwise uses the padded + # PS ASM decode; "gluon" forces the Gluon path wherever a build exists; + # "asm" forces the padded PS ASM decode. On gfx942/CDNA3 there is no Gluon + # build, so the ASM path is always used regardless of this setting. + "VLLM_ROCM_AITER_MLA_ASM_PADDING": env_with_choices( + "VLLM_ROCM_AITER_MLA_ASM_PADDING", + "auto", + ["auto", "gluon", "asm"], + case_sensitive=False, + ), + # Whether to use aiter mha ops. + # By default is enabled. + "VLLM_ROCM_USE_AITER_MHA": lambda: ( + os.getenv("VLLM_ROCM_USE_AITER_MHA", "True").lower() in ("true", "1") + ), + # Whether to use aiter fp4 gemm asm. + # By default is disabled. + "VLLM_ROCM_USE_AITER_FP4_ASM_GEMM": lambda: ( + os.getenv("VLLM_ROCM_USE_AITER_FP4_ASM_GEMM", "False").lower() in ("true", "1") + ), + # Whether sparse MLA prefill and decode run on aiter's Triton kernel, which + # reads the KV cache as stored (bf16 or fp8, paged or flat). Used by the + # ROCM_AITER_MLA_SPARSE backend (DeepSeek V3.2, GLM-5.x) and DeepSeek + # V4 / V4.1. gfx950 only. By default is disabled. + "VLLM_ROCM_USE_AITER_TRITON_SPARSE_MLA": lambda: ( + os.getenv("VLLM_ROCM_USE_AITER_TRITON_SPARSE_MLA", "False").lower() + in ("true", "1") + ), + # Whether to use aiter rope. + # By default is disabled. + "VLLM_ROCM_USE_AITER_TRITON_ROPE": lambda: ( + os.getenv("VLLM_ROCM_USE_AITER_TRITON_ROPE", "False").lower() in ("true", "1") + ), + # Whether to use aiter triton fp8 bmm kernel + # By default is enabled. + "VLLM_ROCM_USE_AITER_FP8BMM": lambda: ( + os.getenv("VLLM_ROCM_USE_AITER_FP8BMM", "True").lower() in ("true", "1") + ), + # Whether to use aiter triton fp4 bmm kernel + # By default is enabled. + "VLLM_ROCM_USE_AITER_FP4BMM": lambda: ( + os.getenv("VLLM_ROCM_USE_AITER_FP4BMM", "True").lower() in ("true", "1") + ), + # Use AITER triton unified attention for V1 attention + "VLLM_ROCM_USE_AITER_UNIFIED_ATTENTION": lambda: ( + os.getenv("VLLM_ROCM_USE_AITER_UNIFIED_ATTENTION", "False").lower() + in ("true", "1") + ), + # Whether to use aiter fusion shared experts ops. + # By default is disabled. + "VLLM_ROCM_USE_AITER_FUSION_SHARED_EXPERTS": lambda: ( + os.getenv("VLLM_ROCM_USE_AITER_FUSION_SHARED_EXPERTS", "False").lower() + in ("true", "1") + ), + # Fuse DeepSeek-V4's shared expert into AITER MegaMoE (requantized to + # MXFP4). By default is disabled. + "VLLM_ROCM_AITER_MEGA_MOE_FUSE_SHARED_EXPERTS": lambda: ( + os.getenv("VLLM_ROCM_AITER_MEGA_MOE_FUSE_SHARED_EXPERTS", "False").lower() + in ("true", "1") + ), + # Run the eligible layers of decode steps on the model's mono decode + # kernels: persistent launches a layer, the TP all-reduces in-kernel + # (DeepSeek-V4.1 on CDNA4). By default is disabled. + "VLLM_ROCM_MONO_DECODE": lambda: ( + os.getenv("VLLM_ROCM_MONO_DECODE", "False").lower() in ("true", "1") + ), + # Whether to use aiter triton kernels for gemm ops. + # By default is enabled. + "VLLM_ROCM_USE_AITER_TRITON_GEMM": lambda: ( + os.getenv("VLLM_ROCM_USE_AITER_TRITON_GEMM", "True").lower() in ("true", "1") + ), + # use rocm skinny gemms + "VLLM_ROCM_USE_SKINNY_GEMM": lambda: ( + os.getenv("VLLM_ROCM_USE_SKINNY_GEMM", "True").lower() in ("true", "1") + ), + # Pad the fp8 weights to 256 bytes for ROCm + "VLLM_ROCM_FP8_PADDING": lambda: bool(int(os.getenv("VLLM_ROCM_FP8_PADDING", "1"))), + # Pad the weights for the moe kernel + "VLLM_ROCM_MOE_PADDING": lambda: bool(int(os.getenv("VLLM_ROCM_MOE_PADDING", "1"))), + # Whether to use the shuffled kv cache layout + "VLLM_ROCM_SHUFFLE_KV_CACHE_LAYOUT": lambda: ( + os.getenv("VLLM_ROCM_SHUFFLE_KV_CACHE_LAYOUT", "False").lower() in ("true", "1") + ), + # Custom quick allreduce kernel for MI3* cards + # Choice of quantization level: FP, INT8, INT6, INT4, INT3 or NONE + # Recommended for large models to get allreduce + "VLLM_ROCM_QUICK_REDUCE_QUANTIZATION": env_with_choices( + "VLLM_ROCM_QUICK_REDUCE_QUANTIZATION", + "NONE", + ["FP", "INT8", "INT6", "INT4", "INT3", "NONE"], + ), + # Custom quick allreduce kernel for MI3* cards + # Due to the lack of the bfloat16 asm instruction, bfloat16 + # kernels are slower than fp16, + # If environment variable is set to 1, the input is converted to fp16 + "VLLM_ROCM_QUICK_REDUCE_CAST_BF16_TO_FP16": lambda: ( + os.getenv("VLLM_ROCM_QUICK_REDUCE_CAST_BF16_TO_FP16", "True").lower() + in ("true", "1") + ), + # Custom quick allreduce kernel for MI3* cards. + # Controls the maximum allowed number of data bytes(MB) for custom quick + # allreduce communication. + # Default: 2048 MB. + # Data exceeding this size will use either custom allreduce or RCCL + # communication. + "VLLM_ROCM_QUICK_REDUCE_MAX_SIZE_BYTES_MB": lambda: maybe_convert_int( + os.environ.get("VLLM_ROCM_QUICK_REDUCE_MAX_SIZE_BYTES_MB", None) + ), + # Custom quick allreduce kernel for MI3* cards. + # Controls the minimum allowed number of data bytes(MB) required to use + # custom quick allreduce communication. + # If unset, use the built-in threshold table. + "VLLM_ROCM_QUICK_REDUCE_MIN_SIZE_BYTES_MB": lambda: maybe_convert_int( + os.environ.get("VLLM_ROCM_QUICK_REDUCE_MIN_SIZE_BYTES_MB", None) + ), + # Controls the minimum tensor size (KB, where 1 KB = 1024 bytes) required + # to use the configured QuickReduce codec. Smaller tensors use FP + # QuickReduce. This does not affect QuickReduce eligibility. + "VLLM_ROCM_QUICK_REDUCE_QUANTIZATION_MIN_SIZE_KB": lambda: maybe_convert_int( + os.environ.get("VLLM_ROCM_QUICK_REDUCE_QUANTIZATION_MIN_SIZE_KB", None) + ), + # If set, enable multiprocessing in LLM for the V1 code path. + "VLLM_ENABLE_V1_MULTIPROCESSING": lambda: bool( + int(os.getenv("VLLM_ENABLE_V1_MULTIPROCESSING", "1")) + ), + "VLLM_LOG_BATCHSIZE_INTERVAL": lambda: float( + os.getenv("VLLM_LOG_BATCHSIZE_INTERVAL", "-1") + ), + "VLLM_DISABLE_COMPILE_CACHE": disable_compile_cache, + # If set to "0", disable LayerName opaque type for layer_name + # parameters in custom ops. Defaults to enabled on torch >= 2.11. + "VLLM_USE_LAYERNAME": lambda: bool(int(os.getenv("VLLM_USE_LAYERNAME", "1"))), + # If set, use the Rust frontend binary instead of the Python API server + # process(es). + "VLLM_USE_RUST_FRONTEND": lambda: bool( + int(os.getenv("VLLM_USE_RUST_FRONTEND", "0")) + ), + # If set, use the packaged Rust client for `vllm bench serve`. + "VLLM_USE_RUST_BENCH": lambda: bool(int(os.getenv("VLLM_USE_RUST_BENCH", "0"))), + # Path to the vllm-rs binary. Defaults to "auto" which discovers the + # binary installed with the vllm package. Used when VLLM_USE_RUST_FRONTEND=1 + # or VLLM_USE_RUST_BENCH=1. + "VLLM_RUST_FRONTEND_PATH": lambda: _resolve_rust_cli_path(), + # If set, vllm will run in development mode, which will enable + # some additional endpoints for developing and debugging, + # e.g. `/reset_prefix_cache` + "VLLM_SERVER_DEV_MODE": lambda: bool(int(os.getenv("VLLM_SERVER_DEV_MODE", "0"))), + # Controls the maximum number of requests to handle in a + # single asyncio task when processing per-token outputs in the + # V1 AsyncLLM interface. It is applicable when handling a high + # concurrency of streaming requests. + # Setting this too high can result in a higher variance of + # inter-message latencies. Setting it too low can negatively impact + # TTFT and overall throughput. + "VLLM_V1_OUTPUT_PROC_CHUNK_SIZE": lambda: int( + os.getenv("VLLM_V1_OUTPUT_PROC_CHUNK_SIZE", "128") + ), + # If set, vLLM will disable the MLA attention optimizations. + "VLLM_MLA_DISABLE": lambda: bool(int(os.getenv("VLLM_MLA_DISABLE", "0"))), + # If set, vLLM will pick up the provided Flash Attention MLA + # Number of GPUs per worker in Ray, if it is set to be a fraction, + # it allows ray to schedule multiple actors on a single GPU, + # so that users can colocate other actors on the same GPUs as vLLM. + "VLLM_RAY_PER_WORKER_GPUS": lambda: float( + os.getenv("VLLM_RAY_PER_WORKER_GPUS", "1.0") + ), + # Bundle indices for Ray, if it is set, it can control precisely + # which indices are used for the Ray bundle, for every worker. + # Format: comma-separated list of integers, e.g. "0,1,2,3" + "VLLM_RAY_BUNDLE_INDICES": lambda: os.getenv("VLLM_RAY_BUNDLE_INDICES", ""), + # In some system, find_loaded_library() may not work. So we allow users to + # specify the path through environment variable VLLM_CUDART_SO_PATH. + "VLLM_CUDART_SO_PATH": lambda: os.getenv("VLLM_CUDART_SO_PATH", None), + # Rank of the process in the data parallel setting + "VLLM_DP_RANK": lambda: int(os.getenv("VLLM_DP_RANK", "0")), + # Rank of the process in the data parallel setting. + # Defaults to VLLM_DP_RANK when not set. + "VLLM_DP_RANK_LOCAL": lambda: int( + os.getenv("VLLM_DP_RANK_LOCAL", sys.modules[__name__].VLLM_DP_RANK) + ), + # World size of the data parallel setting + "VLLM_DP_SIZE": lambda: int(os.getenv("VLLM_DP_SIZE", "1")), + # IP address of the master node in the data parallel setting + "VLLM_DP_MASTER_IP": lambda: os.getenv("VLLM_DP_MASTER_IP", "127.0.0.1"), + # Port of the master node in the data parallel setting + "VLLM_DP_MASTER_PORT": lambda: int(os.getenv("VLLM_DP_MASTER_PORT", "0")), + # Randomize inputs during dummy runs when using Data Parallel + "VLLM_RANDOMIZE_DP_DUMMY_INPUTS": lambda: ( + os.environ.get("VLLM_RANDOMIZE_DP_DUMMY_INPUTS", "0") == "1" + ), + # Strategy to pack the data parallel ranks for Ray. + # Available options: + # - "fill": + # for DP master node, allocate exactly data-parallel-size-local DP ranks, + # for non-master nodes, allocate as many DP ranks as can fit; + # - "strict": + # allocate exactly data-parallel-size-local DP ranks to each picked node; + # - "span": + # Should be used only when a single DP rank requires multiple nodes. + # allocate one DP rank over as many nodes as required for set world_size; + # This environment variable is ignored if data-parallel-backend is not Ray. + "VLLM_RAY_DP_PACK_STRATEGY": lambda: os.getenv( + "VLLM_RAY_DP_PACK_STRATEGY", "strict" + ), + # Optional comma-separated list of node IPs that Ray data-parallel + # placement groups may use. When set, create_dp_placement_groups only + # considers these nodes (the DP master node is always included). + # This environment variable is ignored if data-parallel-backend is not Ray. + "VLLM_RAY_DP_PLACEMENT_NODE_IPS": lambda: os.getenv( + "VLLM_RAY_DP_PLACEMENT_NODE_IPS", "" + ), + # Comma-separated *additional* prefixes of env vars to copy from the + # driver to Ray workers. These are merged with the built-in defaults + # defined in ``vllm.ray.ray_env`` (VLLM_, etc.). Example: "MYLIB_,OTHER_" + "VLLM_RAY_EXTRA_ENV_VAR_PREFIXES_TO_COPY": lambda: os.getenv( + "VLLM_RAY_EXTRA_ENV_VAR_PREFIXES_TO_COPY", "" + ), + # Comma-separated *additional* individual env var names to copy from + # the driver to Ray workers. Merged with the built-in defaults + # defined in ``vllm.ray.ray_env`` (PYTHONHASHSEED). + # Example: "MY_SECRET,MY_FLAG" + "VLLM_RAY_EXTRA_ENV_VARS_TO_COPY": lambda: os.getenv( + "VLLM_RAY_EXTRA_ENV_VARS_TO_COPY", "" + ), + # Use model_redirect to redirect the model name to a local folder. + # `model_redirect` can be a json file mapping the model between + # repo_id and local folder: + # {"meta-llama/Llama-3.2-1B": "/tmp/Llama-3.2-1B"} + # or a space separated values table file: + # meta-llama/Llama-3.2-1B /tmp/Llama-3.2-1B + "VLLM_MODEL_REDIRECT_PATH": lambda: os.environ.get( + "VLLM_MODEL_REDIRECT_PATH", None + ), + # Whether to use atomicAdd reduce in gptq/awq marlin kernel. + "VLLM_MARLIN_USE_ATOMIC_ADD": lambda: ( + os.environ.get("VLLM_MARLIN_USE_ATOMIC_ADD", "0") == "1" + ), + # The activation dtype for marlin kernel + "VLLM_MARLIN_INPUT_DTYPE": env_with_choices( + "VLLM_MARLIN_INPUT_DTYPE", None, ["int8", "fp8"] + ), + # The online quantization dtype for humming kernel + "VLLM_HUMMING_ONLINE_QUANT_CONFIG": lambda: maybe_convert_json_str_or_file( + os.environ.get("VLLM_HUMMING_ONLINE_QUANT_CONFIG", None) + ), + # The activation dtype config for humming kernel. Explicit schemas disable + # fallback unless the config includes "allow_fallback": true. + "VLLM_HUMMING_INPUT_QUANT_CONFIG": lambda: maybe_convert_json_str_or_file( + os.environ.get("VLLM_HUMMING_INPUT_QUANT_CONFIG", None) + ), + # Whether to use fp16 accumulator mma + "VLLM_HUMMING_USE_F16_ACCUM": lambda: maybe_convert_bool( + os.environ.get("VLLM_HUMMING_USE_F16_ACCUM", "0") + ), + # Whether to use indexed gemm for humming moe + # if 1, force use indexed gemm + # if 0, force use grouped gemm + # if None, choose better gemm type automatically + "VLLM_HUMMING_MOE_GEMM_TYPE": lambda: os.environ.get( + "VLLM_HUMMING_MOE_GEMM_TYPE", None + ), + # Whether to use DeepEPLL kernels for NVFP4 quantization and dispatch method + # only supported on Blackwell GPUs and with + # https://github.com/deepseek-ai/DeepEP/pull/341 + "VLLM_DEEPEPLL_NVFP4_DISPATCH": lambda: bool( + int(os.getenv("VLLM_DEEPEPLL_NVFP4_DISPATCH", "0")) + ), + # Whether to turn on the outlines cache for V1 + # This cache is unbounded and on disk, so it's not safe to use in + # an environment with potentially malicious users. + "VLLM_V1_USE_OUTLINES_CACHE": lambda: ( + os.environ.get("VLLM_V1_USE_OUTLINES_CACHE", "0") == "1" + ), + # Whether using Pathways + "VLLM_TPU_USING_PATHWAYS": lambda: bool( + "proxy" in os.getenv("JAX_PLATFORMS", "").lower() + ), + # Allow use of DeepGemm kernels for fused moe ops. + "VLLM_USE_DEEP_GEMM": lambda: bool(int(os.getenv("VLLM_USE_DEEP_GEMM", "1"))), + # Allow use of DeepGemm specifically for MoE fused ops (overrides only MoE). + "VLLM_MOE_USE_DEEP_GEMM": lambda: bool( + int(os.getenv("VLLM_MOE_USE_DEEP_GEMM", "1")) + ), + # Whether to use E8M0 scaling when DeepGEMM is used on Blackwell GPUs. + "VLLM_USE_DEEP_GEMM_E8M0": lambda: bool( + int(os.getenv("VLLM_USE_DEEP_GEMM_E8M0", "1")) + ), + # Whether to create TMA-aligned scale tensor when DeepGEMM is used. + "VLLM_USE_DEEP_GEMM_TMA_ALIGNED_SCALES": lambda: bool( + int(os.getenv("VLLM_USE_DEEP_GEMM_TMA_ALIGNED_SCALES", "1")) + ), + # Opt-in MLA DCP query replication: skip the decode query all-gather. + "VLLM_DCP_Q_REPLICATE": lambda: bool(int(os.getenv("VLLM_DCP_Q_REPLICATE", "0"))), + # DeepGemm JITs the kernels on-demand. The warmup attempts to make DeepGemm + # JIT all the required kernels before model execution so there is no + # JIT'ing in the hot-path. However, this warmup increases the engine + # startup time by a couple of minutes. + # Available options: + # - "skip" : Skip warmup. + # - "full" : Warmup deepgemm by running all possible gemm shapes the + # engine could encounter. + # - "relax" : Select gemm shapes to run based on some heuristics. The + # heuristic aims to have the same effect as running all possible gemm + # shapes, but provides no guarantees. + "VLLM_DEEP_GEMM_WARMUP": env_with_choices( + "VLLM_DEEP_GEMM_WARMUP", + "relax", + [ + "skip", + "full", + "relax", + ], + ), + # Whether to use fused grouped_topk used for MoE expert selection. + "VLLM_USE_FUSED_MOE_GROUPED_TOPK": lambda: bool( + int(os.getenv("VLLM_USE_FUSED_MOE_GROUPED_TOPK", "1")) + ), + # Skip cudagraph/DP padding tokens in the MoE path by forcing their expert + # ids to -1 so the dispatch and experts drop them. Requires a MoE kernel that + # treats topk_id == -1 as a skip sentinel + "VLLM_MOE_SKIP_PADDING": lambda: bool(int(os.getenv("VLLM_MOE_SKIP_PADDING", "1"))), + # Kimi-K3 only. Under sequence-parallel MoE the dense and shared-expert MLPs + # are replicated on every rank, so each rank streams the whole weight to + # serve its own token shard. Shard them across TP instead: the MLP then + # all-gathers the full token set, computes this rank's partial, and + # reduce-scatters (which both sums across TP and restores the sequence + # sharding). Trades weight bandwidth and resident memory for two collectives + # per layer, so it only wins at low token counts: intended for decode + # instances in a P/D disaggregated deployment, not for prefill or unified + # serving. + "VLLM_KIMI_K3_SHARD_SP_SHARED_EXPERT": lambda: bool( + int(os.getenv("VLLM_KIMI_K3_SHARD_SP_SHARED_EXPERT", "0")) + ), + # Kimi K3 only, and unrelated to the MoE flags above. Tap the pre-norm + # AttnRes mixture, rather than the post-mixture sum, as the auxiliary + # hidden state handed to a DFlash drafter. This changes the numerics the + # speculator sees, so it is off by default while the effect is measured. + "VLLM_KIMI_K3_AUX_ATTN_RES_STREAM": lambda: bool( + int(os.getenv("VLLM_KIMI_K3_AUX_ATTN_RES_STREAM", "0")) + ), + # Use the SM100 BF16 GEMM-AR kernel for eligible Kimi-K3 row-parallel + # attention projections. All TP ranks must belong to one NVLink domain. + "VLLM_KIMI_K3_GEMM_AR": lambda: bool(int(os.getenv("VLLM_KIMI_K3_GEMM_AR", "1"))), + # Fuse eligible sequence-parallel row-parallel projections with their TP + # reduce-scatter using the SM100 BF16/MXFP8 GEMM-RS kernel (Kimi-K3 + # attention/shared-expert projections, DeepSeek-V4.1 ``wo_b``). All TP + # ranks must belong to one NVLink domain. + "VLLM_ENABLE_GEMM_RS": lambda: bool(int(os.getenv("VLLM_ENABLE_GEMM_RS", "0"))), + # Allow use of FlashInfer FP8 block-scale GEMM for linear layers. + # This uses TensorRT-LLM kernels and requires SM90+ (Hopper). + "VLLM_BLOCKSCALE_FP8_GEMM_FLASHINFER": lambda: bool( + int(os.getenv("VLLM_BLOCKSCALE_FP8_GEMM_FLASHINFER", "1")) + ), + # Force b12x FP4 MoE to use BF16 activations. + "VLLM_B12X_MOE_FP4_FORCE_A16": lambda: bool( + int(os.getenv("VLLM_B12X_MOE_FP4_FORCE_A16", "0")) + ), + # Allow use of FlashInfer MxInt4 MoE kernels for fused moe ops. + "VLLM_USE_FLASHINFER_MOE_INT4": lambda: bool( + int(os.getenv("VLLM_USE_FLASHINFER_MOE_INT4", "0")) + ), + # Control the cache sized used by the xgrammar compiler. The default + # of 512 MB should be enough for roughly 1000 JSON schemas. + # It can be changed with this variable if needed for some reason. + "VLLM_XGRAMMAR_CACHE_MB": lambda: int(os.getenv("VLLM_XGRAMMAR_CACHE_MB", "512")), + # Maximum time in seconds allowed for regex compilation in structured + # output backends (xgrammar, outlines). Prevents ReDoS attacks where + # adversarial patterns cause exponential DFA state-space explosion. + # Set to 0 to disable the timeout (not recommended in production). + "VLLM_REGEX_COMPILATION_TIMEOUT_S": lambda: int( + os.getenv("VLLM_REGEX_COMPILATION_TIMEOUT_S", "5") + ), + # Control the threshold for msgspec to use 'zero copy' for + # serialization/deserialization of tensors. Tensors below + # this limit will be encoded into the msgpack buffer, and + # tensors above will instead be sent via a separate message. + # While the sending side still actually copies the tensor + # in all cases, on the receiving side, tensors above this + # limit will actually be zero-copy decoded. The unit is bytes. + "VLLM_MSGPACK_ZERO_COPY_THRESHOLD": lambda: int( + os.getenv("VLLM_MSGPACK_ZERO_COPY_THRESHOLD", "256") + ), + # If set, allow insecure serialization using pickle. + # This is useful for environments where it is deemed safe to use the + # insecure method and it is needed for some reason. + "VLLM_ALLOW_INSECURE_SERIALIZATION": lambda: bool( + int(os.getenv("VLLM_ALLOW_INSECURE_SERIALIZATION", "0")) + ), + # Temporary: skip adding random suffix to internal request IDs. May be + # needed for KV connectors that match request IDs across instances. + "VLLM_DISABLE_REQUEST_ID_RANDOMIZATION": lambda: bool( + int(os.getenv("VLLM_DISABLE_REQUEST_ID_RANDOMIZATION", "0")) + ), + # IP address used for NIXL handshake between remote agents. + "VLLM_NIXL_SIDE_CHANNEL_HOST": lambda: os.getenv( + "VLLM_NIXL_SIDE_CHANNEL_HOST", "localhost" + ), + # Port used for NIXL handshake between remote agents. + "VLLM_NIXL_SIDE_CHANNEL_PORT": lambda: int( + os.getenv("VLLM_NIXL_SIDE_CHANNEL_PORT", "5600") + ), + # Address the P2P KV-offload control socket binds to. Defaults to + # ``localhost`` (loopback only); must be set to the node IP for + # cross-host P2P so remote peers can reach the socket. + "VLLM_P2P_SIDE_CHANNEL_HOST": lambda: os.getenv( + "VLLM_P2P_SIDE_CHANNEL_HOST", "localhost" + ), + # Port the P2P KV-offload control socket binds to. + "VLLM_P2P_SIDE_CHANNEL_PORT": lambda: int( + os.getenv("VLLM_P2P_SIDE_CHANNEL_PORT", "5710") + ), + # Maximum number of concurrent P2P peer sessions. Limits ZMQ socket + # consumption to prevent denial-of-service via unbounded peer + # creation from client-supplied kv_transfer_params. Default: 128. + "VLLM_P2P_MAX_PEERS": lambda: int(os.getenv("VLLM_P2P_MAX_PEERS", "128")), + # Seconds to wait for a P2P peer handshake to complete (connected + # but not yet ready). Sessions exceeding this deadline are reaped. + # Prevents never-responding peers from accumulating indefinitely. + "VLLM_P2P_HANDSHAKE_TIMEOUT_S": lambda: int( + os.getenv("VLLM_P2P_HANDSHAKE_TIMEOUT_S", "30") + ), + # Seconds a P2P session may remain idle (no pending work, no + # messages) before it is eligible for eviction. Idle eviction runs + # only when remaining peer slots are within 8 of VLLM_P2P_MAX_PEERS. + # Default: 300. Set to 0 to disable. + "VLLM_P2P_IDLE_TIMEOUT_S": lambda: int(os.getenv("VLLM_P2P_IDLE_TIMEOUT_S", "300")), + # IP address used for the EC connector's ZMQ side channel + # (producer ROUTER bind, consumer DEALER dial). + "VLLM_EC_SIDE_CHANNEL_HOST": lambda: os.getenv( + "VLLM_EC_SIDE_CHANNEL_HOST", "localhost" + ), + # Port for the EC connector's ZMQ side channel; advertised to peers + # via `ec_transfer_params.peer_port` on the producer's response. + "VLLM_EC_SIDE_CHANNEL_PORT": lambda: int( + os.getenv("VLLM_EC_SIDE_CHANNEL_PORT", "5601") + ), + # Per-request timeout (seconds) when a prefiller worker registers with + # the Mooncake bootstrap server. + "VLLM_MOONCAKE_CONNECTOR_TIMEOUT": lambda: float( + os.getenv("VLLM_MOONCAKE_CONNECTOR_TIMEOUT", "30.0") + ), + # Port used for Mooncake handshake between remote agents. + "VLLM_MOONCAKE_BOOTSTRAP_PORT": lambda: int( + os.getenv("VLLM_MOONCAKE_BOOTSTRAP_PORT", "8998") + ), + # Log per-batch memory/disk tier breakdown on external GETs. + "VLLM_MOONCAKE_STORE_TIER_LOG": lambda: ( + os.getenv("VLLM_MOONCAKE_STORE_TIER_LOG", "False").lower() in ("true", "1") + ), + # Number of parallel KV-load receive threads per worker rank. Lets the + # per-request control overhead (Python prep + master key lookup) of one + # request overlap with the RDMA transfer of another, keeping the transfer + # engine's queue pairs busy. Helps when that overhead is significant or + # per-request batches are too small to saturate the link on their own. + "VLLM_MOONCAKE_LOAD_RECV_THREADS": lambda: int( + os.getenv("VLLM_MOONCAKE_LOAD_RECV_THREADS", "1") + ), + # Fraction of the owner's DirectIO staging buffer to fill per GET batch. + "VLLM_MOONCAKE_DISK_STAGING_USABLE_RATIO": lambda: float( + os.getenv("VLLM_MOONCAKE_DISK_STAGING_USABLE_RATIO", "0.9") + ), + # Pin this rank to a specific owner segment ("host:port"). + "MOONCAKE_PREFERRED_SEGMENT": lambda: os.getenv("MOONCAKE_PREFERRED_SEGMENT"), + # Override the hostname the rank registers as a Mooncake requester. + "MOONCAKE_REQUESTER_LOCAL_HOSTNAME": lambda: os.getenv( + "MOONCAKE_REQUESTER_LOCAL_HOSTNAME" + ), + # Override the directory for the FlashInfer autotune config cache. + "VLLM_FLASHINFER_AUTOTUNE_CACHE_DIR": lambda: os.getenv( + "VLLM_FLASHINFER_AUTOTUNE_CACHE_DIR", None + ), + # Comma-separated FlashInfer op names to exclude from autotuning, using + # the heuristic fallback tactic instead. Unset: skip "fp4_gemm" when the + # CuTe-DSL NVFP4 linear kernel is selected. Empty: skip nothing. + "VLLM_FLASHINFER_AUTOTUNE_SKIP_OPS": lambda: ( + None + if "VLLM_FLASHINFER_AUTOTUNE_SKIP_OPS" not in os.environ + else [ + v.strip() + for v in os.environ["VLLM_FLASHINFER_AUTOTUNE_SKIP_OPS"].split(",") + if v.strip() + ] + ), + # Flashinfer fused allreduce backend. + "VLLM_FLASHINFER_ALLREDUCE_BACKEND": env_with_choices( + "VLLM_FLASHINFER_ALLREDUCE_BACKEND", + "auto", + ["auto", "trtllm", "mnnvl"], + ), + # Control the workspace buffer size for the FlashInfer backend. + "VLLM_FLASHINFER_WORKSPACE_BUFFER_SIZE": lambda: int( + os.getenv("VLLM_FLASHINFER_WORKSPACE_BUFFER_SIZE", str(394 * 1024 * 1024)) + ), + # Transmit MoE all-to-all combine (expert-output) payloads in FP8 instead + # of BF16, halving NVLink traffic on the combine leg. Only takes effect + # when the installed FlashInfer MoeAlltoAll kernel supports it. + "VLLM_FLASHINFER_MOE_A2A_LOW_PRECISION_COMBINE": lambda: bool( + int(os.getenv("VLLM_FLASHINFER_MOE_A2A_LOW_PRECISION_COMBINE", "0")) + ), + # Control the maximum number of tokens per expert supported by the + # NVFP4 MoE CUTLASS Kernel. This value is used to create a buffer for + # the blockscale tensor of activations NVFP4 Quantization. + # This is used to prevent the kernel from running out of memory. + "VLLM_MAX_TOKENS_PER_EXPERT_FP4_MOE": lambda: int( + os.getenv("VLLM_MAX_TOKENS_PER_EXPERT_FP4_MOE", "163840") + ), + # MoE routing strategy selector. + # See `RoutingSimulator.get_available_strategies()` # for available + # strategies. + # Custom routing strategies can be registered by + # RoutingSimulator.register_strategy() + # Note: custom strategies may not produce correct model outputs + "VLLM_MOE_ROUTING_SIMULATION_STRATEGY": lambda: os.environ.get( + "VLLM_MOE_ROUTING_SIMULATION_STRATEGY", "" + ).lower(), + # Regex timeout for use by the vLLM tool parsing plugins. + "VLLM_TOOL_PARSE_REGEX_TIMEOUT_SECONDS": lambda: int( + os.getenv("VLLM_TOOL_PARSE_REGEX_TIMEOUT_SECONDS", "1") + ), + # Enforce function parameter schemas in structural-tag based tool calling. + "VLLM_ENFORCE_STRICT_TOOL_CALLING": lambda: ( + os.getenv("VLLM_ENFORCE_STRICT_TOOL_CALLING", "True").lower() in ("true", "1") + ), + # Control the max chunk bytes (in MB) for the rpc message queue. + # Object larger than this threshold will be broadcast to worker + # processes via zmq. + "VLLM_MQ_MAX_CHUNK_BYTES_MB": lambda: int( + os.getenv("VLLM_MQ_MAX_CHUNK_BYTES_MB", "16") + ), + # Timeout in seconds for execute_model RPC calls in multiprocessing + # executor (only applies when TP > 1). + "VLLM_EXECUTE_MODEL_TIMEOUT_SECONDS": lambda: int( + os.getenv("VLLM_EXECUTE_MODEL_TIMEOUT_SECONDS", "300") + ), + # Timeout in seconds for engine and worker process shutdown + "VLLM_WORKER_SHUTDOWN_TIMEOUT_SECONDS": lambda: int( + os.getenv("VLLM_WORKER_SHUTDOWN_TIMEOUT_SECONDS", "5") + ), + # KV Cache layout used throughout vllm. + # Some common values are: + # - LBNHC + # - LBHNC + # Where N=num_states, H=num_heads and C=state_content. The default value will + # leave the layout choice to the backend. Mind that backends may only + # implement and support a subset of all possible layouts. + # LHBNC hoists the head dim outside the block dim; backends must opt in via + # AttentionBackend.supported_kv_cache_layouts(). + "VLLM_KV_CACHE_LAYOUT": env_with_choices( + "VLLM_KV_CACHE_LAYOUT", + None, + ["LBNHC", "LBHNC", "LHBNC", "NHD", "HND", "BLHNC", "BLNHC", "BHLNC"], + ), + # SSM conv state layout used for Mamba models. + # - SD: (state_len, dim) — dim contiguous (default) + # - DS: (dim, state_len) — TP-sharded dim on dim1, + # consistent with SSM temporal state and LBHNC KV cache layout. + "VLLM_SSM_CONV_STATE_LAYOUT": env_with_choices( + "VLLM_SSM_CONV_STATE_LAYOUT", None, ["SD", "DS"] + ), + # Enable checking whether the generated logits contain NaNs, + # indicating corrupted output. Useful for debugging low level bugs + # or bad hardware but it may add compute overhead. + "VLLM_COMPUTE_NANS_IN_LOGITS": lambda: bool( + int(os.getenv("VLLM_COMPUTE_NANS_IN_LOGITS", "0")) + or int(os.getenv("VLLM_RAISE_ON_LOGIT_NANS", "0")) + ), + # Raise an exception when generated logits contain NaNs. Enabling this + # also enables the NaN computation required to detect them. + "VLLM_RAISE_ON_LOGIT_NANS": lambda: bool( + int(os.getenv("VLLM_RAISE_ON_LOGIT_NANS", "0")) + ), + # Timeout (in seconds) for MooncakeConnector in PD disaggregated setup. + "VLLM_MOONCAKE_ABORT_REQUEST_TIMEOUT": lambda: int( + os.getenv("VLLM_MOONCAKE_ABORT_REQUEST_TIMEOUT", "480") + ), + # If set, it means we pre-downloaded cubin files and flashinfer will + # read the cubin files directly. + "VLLM_HAS_FLASHINFER_CUBIN": lambda: bool( + int(os.getenv("VLLM_HAS_FLASHINFER_CUBIN", "0")) + ), + # Controls garbage collection during CUDA graph capture. + # If set to 0 (default), enables GC freezing to speed up capture time. + # If set to 1, allows GC to run during capture. + "VLLM_ENABLE_CUDAGRAPH_GC": lambda: bool( + int(os.getenv("VLLM_ENABLE_CUDAGRAPH_GC", "0")) + ), + # Used to force set up loopback IP + "VLLM_LOOPBACK_IP": lambda: os.getenv("VLLM_LOOPBACK_IP", ""), + # Used to set the process name prefix for vLLM processes. + # This is useful for debugging and monitoring purposes. + # The default value is "VLLM". + "VLLM_PROCESS_NAME_PREFIX": lambda: os.getenv("VLLM_PROCESS_NAME_PREFIX", "VLLM"), + # Allow chunked local attention with hybrid kv cache manager. + # Currently using the Hybrid KV cache manager with chunked local attention + # in the Llama4 models (the only models currently using chunked local attn) + # causes a latency regression. For this reason, we disable it by default. + # This flag is used to allow users to enable it if they want to (to save on + # kv-cache memory usage and enable longer contexts) + # TODO(lucas): Remove this flag once latency regression is resolved. + "VLLM_ALLOW_CHUNKED_LOCAL_ATTN_WITH_HYBRID_KV_CACHE": lambda: bool( + int(os.getenv("VLLM_ALLOW_CHUNKED_LOCAL_ATTN_WITH_HYBRID_KV_CACHE", "1")) + ), + # Enables support for the "store" option in the OpenAI Responses API. + # When set to 1, vLLM's OpenAI server will retain the input and output + # messages for those requests in memory. By default, this is disabled (0), + # and the "store" option is ignored. + # NOTE/WARNING: + # 1. Messages are kept in memory only (not persisted to disk) and will be + # lost when the vLLM server shuts down. + # 2. Enabling this option will cause a memory leak, as stored messages are + # never removed from memory until the server terminates. + "VLLM_ENABLE_RESPONSES_API_STORE": lambda: bool( + int(os.getenv("VLLM_ENABLE_RESPONSES_API_STORE", "0")) + ), + # If set to 1, expose the Cohere Chat v2 API at ``POST /cohere/v2/chat`` + # and its render endpoint at ``POST /cohere/v2/chat/render``. + # Default off + "VLLM_ENABLE_COHERE_API": lambda: bool( + int(os.getenv("VLLM_ENABLE_COHERE_API", "0")) + ), + # If set, use the fp8 mfma in rocm paged attention. + "VLLM_ROCM_FP8_MFMA_PAGE_ATTN": lambda: bool( + int(os.getenv("VLLM_ROCM_FP8_MFMA_PAGE_ATTN", "0")) + ), + # Whether to use pytorch symmetric memory for allreduce + "VLLM_ALLREDUCE_USE_SYMM_MEM": lambda: bool( + int(os.getenv("VLLM_ALLREDUCE_USE_SYMM_MEM", "1")) + ), + # Whether to use FlashInfer allreduce + "VLLM_ALLREDUCE_USE_FLASHINFER": lambda: bool( + int(os.getenv("VLLM_ALLREDUCE_USE_FLASHINFER", "1")) + ), + # Whether to use FlashInfer's single-node PCIe CUDA-IPC all-reduce. + # This backend has a strict single-stream contract and is opt-in while its + # integration is being qualified. + "VLLM_ALLREDUCE_USE_FLASHINFER_PCIE_IPC": lambda: bool( + int(os.getenv("VLLM_ALLREDUCE_USE_FLASHINFER_PCIE_IPC", "0")) + ), + # Experimental: use this to enable MCP tool calling for non harmony models + "VLLM_USE_EXPERIMENTAL_PARSER_CONTEXT": lambda: bool( + int(os.getenv("VLLM_USE_EXPERIMENTAL_PARSER_CONTEXT", "0")) + ), + # User override folder for tuned Triton-kernel configs. Shared by MoE, + # Mamba SSU, and LoRA. Filenames are distinct so one folder can hold all. + # Each component first checks this folder, then the configs shipped with + # vLLM (if any). If no JSON matches, it uses a hard-coded heuristic. + "VLLM_TUNED_CONFIG_FOLDER": lambda: os.getenv("VLLM_TUNED_CONFIG_FOLDER", None), + # Opt-in persistence of the startup plan. When enabled, each worker + # saves the memory-profiling result (the suggested --kv-cache-memory value + # and the free-memory baseline) under VLLM_CACHE_ROOT/startup_plan/, + # keyed by a hardware+config fingerprint, and later boots auto-apply it + # -- skipping memory profiling -- when the fingerprint matches and + # current free memory >= the recorded baseline. + # See vllm/v1/worker/startup_plan.py. + "VLLM_ENABLE_STARTUP_PLAN": lambda: bool( + int(os.getenv("VLLM_ENABLE_STARTUP_PLAN", "0")) + ), + # Valid values are container,code_interpreter,web_search_preview + # ex VLLM_GPT_OSS_SYSTEM_TOOL_MCP_LABELS=container,code_interpreter + # If the server_label of your mcp tool is not in this list it will + # be completely ignored. + "VLLM_GPT_OSS_SYSTEM_TOOL_MCP_LABELS": env_set_with_choices( + "VLLM_GPT_OSS_SYSTEM_TOOL_MCP_LABELS", + default=[], + choices=["container", "code_interpreter", "web_search_preview"], + ), + # Allows harmony instructions to be injected on system messages + "VLLM_GPT_OSS_HARMONY_SYSTEM_INSTRUCTIONS": lambda: bool( + int(os.getenv("VLLM_GPT_OSS_HARMONY_SYSTEM_INSTRUCTIONS", "0")) + ), + # Pin the conversation start date injected into the Harmony system + # message. When unset the current date is used, which introduces + # non-determinism (different tokens -> different model behaviour at + # temperature=0). Set to an ISO date string, e.g. "2023-09-12", + # for reproducible inference or testing. + "VLLM_SYSTEM_START_DATE": lambda: os.getenv("VLLM_SYSTEM_START_DATE", None), + # Enable automatic retry when tool call JSON parsing fails + # If enabled, returns an error message to the model to retry + # If disabled (default), raises an exception and fails the request + "VLLM_TOOL_JSON_ERROR_AUTOMATIC_RETRY": lambda: bool( + int(os.getenv("VLLM_TOOL_JSON_ERROR_AUTOMATIC_RETRY", "0")) + ), + # Add optional custom scopes for profiling, disable to avoid overheads + "VLLM_CUSTOM_SCOPES_FOR_PROFILING": lambda: bool( + int(os.getenv("VLLM_CUSTOM_SCOPES_FOR_PROFILING", "0")) + ), + # Add optional nvtx scopes for profiling, disable to avoid overheads + "VLLM_NVTX_SCOPES_FOR_PROFILING": lambda: bool( + int(os.getenv("VLLM_NVTX_SCOPES_FOR_PROFILING", "0")) + ), + # Represent block hashes in KV cache events as 64-bit integers instead of + # raw bytes. Defaults to True for backward compatibility. + "VLLM_KV_EVENTS_USE_INT_BLOCK_HASHES": lambda: bool( + int(os.getenv("VLLM_KV_EVENTS_USE_INT_BLOCK_HASHES", "1")) + ), + # Name of the shared memory buffer used for object storage. + # Only effective when mm_config.mm_processor_cache_type == "shm". + # Automatically generates a unique UUID-based name per process tree + # if not explicitly set. + "VLLM_OBJECT_STORAGE_SHM_BUFFER_NAME": get_env_or_set_default( + "VLLM_OBJECT_STORAGE_SHM_BUFFER_NAME", + _generate_shm_name, + ), + # The size in MB of the buffers (NVL and RDMA) used by DeepEP + "VLLM_DEEPEP_BUFFER_SIZE_MB": lambda: int( + os.getenv("VLLM_DEEPEP_BUFFER_SIZE_MB", "1024") + ), + # Force DeepEP to use intranode kernel for inter-node communication in + # high throughput mode. This is useful archive higher prefill throughput + # on system supports multi-node nvlink (e.g GB200). + "VLLM_DEEPEP_HIGH_THROUGHPUT_FORCE_INTRA_NODE": lambda: bool( + int(os.getenv("VLLM_DEEPEP_HIGH_THROUGHPUT_FORCE_INTRA_NODE", "0")) + ), + # Allow DeepEP to use MNNVL (multi-node nvlink) for internode_ll kernel, + # turn this for better latency on GB200 like system + "VLLM_DEEPEP_LOW_LATENCY_USE_MNNVL": lambda: bool( + int(os.getenv("VLLM_DEEPEP_LOW_LATENCY_USE_MNNVL", "0")) + ), + # DeepEP v2: use NVLink+RDMA hybrid communication across NVLink domains. + # * hybrid mode = multi-node RDMA + # * non hybrid mode = nvlink + # Set to 1 (the default) to allow DeepEP to autoselect hybrid vs non-hybrid. + # Set to 0 to disable hybrid mode. + "VLLM_DEEPEP_V2_ALLOW_HYBRID_MODE": lambda: bool( + int(os.getenv("VLLM_DEEPEP_V2_ALLOW_HYBRID_MODE", "1")) + ), + # DeepEP v2: use fewer SMs at slight throughput cost + "VLLM_DEEPEP_V2_PREFER_OVERLAP": lambda: bool( + int(os.getenv("VLLM_DEEPEP_V2_PREFER_OVERLAP", "0")) + ), + # DeepEP v2: trade precision for transfer size in combine + "VLLM_DEEPEP_V2_ALLOW_MULTIPLE_REDUCTION": lambda: bool( + int(os.getenv("VLLM_DEEPEP_V2_ALLOW_MULTIPLE_REDUCTION", "0")) + ), + # The number of SMs/CUs to allocate for communication kernels when + # running DBO; the rest will be allocated to compute. + # Default: 20 on CUDA (SMs), 64 on ROCm (CUs). + "VLLM_DBO_COMM_SMS": lambda: int( + os.getenv( + "VLLM_DBO_COMM_SMS", + "64" + if hasattr(__import__("torch").version, "hip") + and __import__("torch").version.hip is not None + else "20", + ) + ), + # Enable max_autotune & coordinate_descent_tuning in inductor_config + # to compile static shapes passed from compile_sizes in compilation_config + # If set to 1, enable max_autotune; By default, this is enabled (1) + "VLLM_ENABLE_INDUCTOR_MAX_AUTOTUNE": lambda: bool( + int(os.getenv("VLLM_ENABLE_INDUCTOR_MAX_AUTOTUNE", "1")) + ), + # If set to 1, enable coordinate_descent_tuning; + # By default, this is enabled (1) + "VLLM_ENABLE_INDUCTOR_COORDINATE_DESCENT_TUNING": lambda: bool( + int(os.getenv("VLLM_ENABLE_INDUCTOR_COORDINATE_DESCENT_TUNING", "1")) + ), + # Flag to enable NCCL symmetric memory allocation and registration + "VLLM_USE_NCCL_SYMM_MEM": lambda: bool( + int(os.getenv("VLLM_USE_NCCL_SYMM_MEM", "0")) + ), + # NCCL header path + "VLLM_NCCL_INCLUDE_PATH": lambda: os.environ.get("VLLM_NCCL_INCLUDE_PATH", None), + # GC debug config + # - VLLM_GC_DEBUG=0: disable GC debugger + # - VLLM_GC_DEBUG=1: enable GC debugger with gc.collect elpased times + # - VLLM_GC_DEBUG='{"top_objects":5}': enable GC debugger with + # top 5 collected objects + "VLLM_GC_DEBUG": lambda: os.getenv("VLLM_GC_DEBUG", ""), + # Debug workspace allocations. + # logging of workspace resize operations. + "VLLM_DEBUG_WORKSPACE": lambda: bool(int(os.getenv("VLLM_DEBUG_WORKSPACE", "0"))), + # Disables parallel execution of shared_experts via separate cuda stream + "VLLM_DISABLE_SHARED_EXPERTS_STREAM": lambda: bool( + int(os.getenv("VLLM_DISABLE_SHARED_EXPERTS_STREAM", "0")) + ), + # Emergency rollback for the DeepSeek-V4 NVIDIA MegaMoE path. By default, + # DeepGEMM computes replicated FP8 shared experts in the same persistent + # SM100 kernel as the routed FP4 experts. + "VLLM_DISABLE_DSV4_MEGAMOE_SHARED_EXPERT_FUSION": lambda: bool( + int(os.getenv("VLLM_DISABLE_DSV4_MEGAMOE_SHARED_EXPERT_FUSION", "0")) + ), + # Limits when we run shared_experts in a separate stream. + # We found out that for large batch sizes, the separate stream + # execution is not beneficial (most likely because of the input clone) + # TODO(alexm-redhat): Tune to be more dynamic based on GPU type + "VLLM_SHARED_EXPERTS_STREAM_TOKEN_THRESHOLD": lambda: int( + int(os.getenv("VLLM_SHARED_EXPERTS_STREAM_TOKEN_THRESHOLD", 256)) + ), + # Token-count cutoff for multi-stream overlap of the attention input + # GEMM with auxiliary GEMMs (e.g. fused_wqa_wkv overlapped with indexer + # weights / kv-score projections in DeepSeek-V4). At or below this many + # tokens the FP8 main GEMM has idle SMs to share with the bf16 aux GEMMs + # and overlap is a 5-45% win; above it the FP8 GEMM saturates the device + # and the cross-stream sync becomes pure overhead. Set to 0 to disable + # the multi-stream path entirely. See #PR 41526 for the empirical result + # for the default value of 1024 tokens. + "VLLM_MULTI_STREAM_GEMM_TOKEN_THRESHOLD": lambda: int( + os.getenv("VLLM_MULTI_STREAM_GEMM_TOKEN_THRESHOLD", "1024") + ), + # Format for saving torch.compile cache artifacts + # - "binary": saves as binary file + # Safe for multiple vllm serve processes accessing the same torch compile cache. + # - "unpacked": saves as directory structure (for inspection/debugging) + # NOT multiprocess safe - race conditions may occur with multiple processes. + # Allows viewing and setting breakpoints in Inductor's code output files. + "VLLM_COMPILE_CACHE_SAVE_FORMAT": env_with_choices( + "VLLM_COMPILE_CACHE_SAVE_FORMAT", "binary", ["binary", "unpacked"] + ), + # Flag to control the v2 model runner. If unset, use config defaults. + "VLLM_USE_V2_MODEL_RUNNER": lambda: maybe_convert_bool( + os.getenv("VLLM_USE_V2_MODEL_RUNNER", None) + ), + # Log model inspection after loading. + # If enabled, logs a transformers-style hierarchical view of the model + # with quantization methods and attention backends. + "VLLM_LOG_MODEL_INSPECTION": lambda: bool( + int(os.getenv("VLLM_LOG_MODEL_INSPECTION", "0")) + ), + # Debug logging for --enable-mfu-metrics + "VLLM_DEBUG_MFU_METRICS": lambda: bool( + int(os.getenv("VLLM_DEBUG_MFU_METRICS", "0")) + ), + # Disable using pytorch's pin memory for CPU offloading. + "VLLM_WEIGHT_OFFLOADING_DISABLE_PIN_MEMORY": lambda: bool( + int(os.getenv("VLLM_WEIGHT_OFFLOADING_DISABLE_PIN_MEMORY", "0")) + ), + # Disable using UVA (Unified Virtual Addressing) for CPU offloading. + "VLLM_WEIGHT_OFFLOADING_DISABLE_UVA": lambda: bool( + int(os.getenv("VLLM_WEIGHT_OFFLOADING_DISABLE_UVA", "0")) + ), + # Max descriptors per CPU-KV-offload batch-memcpy call. 0 = platform default + # (ROCm chunks at 8192, since hipMemcpyBatchAsync faults above that on + # rocm 7.14/7.15 when batch copy is optimized; CUDA uncapped). Set >0 to override. + "VLLM_KV_OFFLOAD_MAX_BATCH_DESCRIPTORS": lambda: int( + os.getenv("VLLM_KV_OFFLOAD_MAX_BATCH_DESCRIPTORS", "0") + ), + # On WSL2 with a compatible kernel (>= 4.19.121), pinned memory is + # supported but disabled by default due to a small performance regression. + # Set to 1 when pinned memory or UVA is required (e.g. CPU offloading + # or v2 model runner). + "VLLM_WSL2_ENABLE_PIN_MEMORY": lambda: bool( + int(os.getenv("VLLM_WSL2_ENABLE_PIN_MEMORY", "0")) + ), + # Disable logging of vLLM logo at server startup time. + "VLLM_DISABLE_LOG_LOGO": lambda: bool(int(os.getenv("VLLM_DISABLE_LOG_LOGO", "0"))), + # Disable PDL for LoRA, as enabling PDL with LoRA on SM100 causes + # Triton compilation to fail. + "VLLM_LORA_DISABLE_PDL": lambda: bool(int(os.getenv("VLLM_LORA_DISABLE_PDL", "0"))), + # Enable CUDA compatibility mode for datacenter GPUs with older + # driver versions than the CUDA toolkit major version of vLLM. + "VLLM_ENABLE_CUDA_COMPATIBILITY": lambda: ( + os.environ.get("VLLM_ENABLE_CUDA_COMPATIBILITY", "0").strip().lower() + in ("1", "true") + ), + # Path to the CUDA compatibility libraries when CUDA compatibility is enabled. + "VLLM_CUDA_COMPATIBILITY_PATH": lambda: os.environ.get( + "VLLM_CUDA_COMPATIBILITY_PATH", None + ), + # Skip model name validation in OpenAI API requests. + # When set to 1, any model name will be accepted in the 'model' field + # of API requests. This is useful for proxy/gateway scenarios where + # the actual model is served but different names may be used in requests. + "VLLM_SKIP_MODEL_NAME_VALIDATION": lambda: ( + os.getenv("VLLM_SKIP_MODEL_NAME_VALIDATION", "0").strip().lower() + in ("1", "true") + ), + # Whether it is a scale up launch engine for elastic EP, + # Should only be set by EngineCoreClient. + "VLLM_ELASTIC_EP_SCALE_UP_LAUNCH": lambda: bool( + int(os.getenv("VLLM_ELASTIC_EP_SCALE_UP_LAUNCH", "0")) + ), + # Whether to wait for all requests to drain before sending the + # scaling command in elastic EP. + "VLLM_ELASTIC_EP_DRAIN_REQUESTS": lambda: bool( + int(os.getenv("VLLM_ELASTIC_EP_DRAIN_REQUESTS", "0")) + ), + # If set to 1, enable CUDA graph memory estimation during memory profiling. + # This profiles CUDA graph memory usage to provide more accurate KV cache + # memory allocation. Enabled by default as of v0.21.0 + "VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS": lambda: bool( + int(os.getenv("VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS", "1")) + ), + # Force N-contiguous weight layout for all XPU unquantized linears. + "VLLM_XPU_FORCE_N_CONTIG_WEIGHT": lambda: bool( + int(os.getenv("VLLM_XPU_FORCE_N_CONTIG_WEIGHT", "0")) + ), + # whether use xpu specific sample kernel + "VLLM_XPU_USE_SAMPLER_KERNEL": lambda: bool( + int(os.getenv("VLLM_XPU_USE_SAMPLER_KERNEL", "1")) + ), + # Kernel backend for INC weight-only intN (WNA16) linear layers on XPU. + # "auto" keeps the default preference order (ARK when importable, else the + # oneDNN w4a16 path). "ark" forces the auto_round_kernel backend, "w4a16" + # forces oneDNN int4_gemm_w4a16, and "w4a8" additionally quantizes + # activations to per-token int8 (int4_gemm_w4a8) for large token counts. + # The two oneDNN backends are int4-only; ARK also serves int2. + # Which one is fastest is device-dependent, so this is left as an opt-in. + "VLLM_XPU_INC_WNA16_BACKEND": env_with_choices( + "VLLM_XPU_INC_WNA16_BACKEND", "auto", ["auto", "ark", "w4a16", "w4a8"] + ), + # Enable simple KV offload. + "VLLM_USE_SIMPLE_KV_OFFLOAD": lambda: bool( + int(os.getenv("VLLM_USE_SIMPLE_KV_OFFLOAD", "0")) + ), + # Direct DCP ops default on when applicable; set to 1 to enforce or 0 to disable. + "VLLM_USE_DIRECT_DCP_A2A": lambda: maybe_convert_bool( + os.getenv("VLLM_USE_DIRECT_DCP_A2A") + ), + "VLLM_USE_DIRECT_DCP_Q_GATHER": lambda: maybe_convert_bool( + os.getenv("VLLM_USE_DIRECT_DCP_Q_GATHER") + ), + "VLLM_USE_DIRECT_DCP_KV_GATHER": lambda: maybe_convert_bool( + os.getenv("VLLM_USE_DIRECT_DCP_KV_GATHER") + ), + # Whether to enable dual cuda streams for LoRA computation + # (used by both BaseLinearLayerWithLoRA and FusedMoEWithLoRA to + # overlap the base layer compute with the LoRA fast path). + "VLLM_LORA_ENABLE_DUAL_STREAM": lambda: bool( + int(os.getenv("VLLM_LORA_ENABLE_DUAL_STREAM", "0")) + ), + # If set to 1, use Python spinloop extension to poll in a more efficient + # way when using the mp backend. + "VLLM_USE_SPINLOOP_EXT": lambda: bool(int(os.getenv("VLLM_USE_SPINLOOP_EXT", "0"))), + # Comma-separated GPU_BDF=NIC_BDF pairs for RDMA NIC selection. + # Must be set together with VLLM_NIC_SELECTION_VARS. + "VLLM_GPU_NIC_PCIE_MAPPING": lambda: os.getenv("VLLM_GPU_NIC_PCIE_MAPPING", ""), + # Comma-separated list of env vars to set from the GPU-NIC mapping. + # Each entry is VAR_NAME or VAR_NAME:<suffix> (suffix appended to + # RDMA device name). Must be set together with VLLM_GPU_NIC_PCIE_MAPPING. + "VLLM_NIC_SELECTION_VARS": lambda: os.getenv("VLLM_NIC_SELECTION_VARS", ""), + # If set to 1, enable the HPC fused kernels (requires the hpc package + # (.so) and an sm100/sm103 device). Covers: + # * the HY V4 iHC ops -- each of the eager HYV4HCPreLayer / + # HYV4HCPostLayer / HYV4HCHeadLayer bodies becomes one kernel launch; + # * the gated-MLA output gating (attn_out * sigmoid(gate projection)), + # fused into gated_mla_gemm; elementwise gating only. + # Each op additionally checks its own shape / dtype constraints and falls + # back to the eager path when they do not hold. + "VLLM_ENABLE_HPC_OPS": lambda: bool(int(os.getenv("VLLM_ENABLE_HPC_OPS", "0"))), + # Whether to skip version suffix when building package + "VLLM_SKIP_VERSION_SUFFIX": lambda: bool( + int(os.getenv("VLLM_SKIP_VERSION_SUFFIX", "0")) + ), +} + + +# --8<-- [end:env-vars-definition] + + +def __getattr__(name: str): + """Gets environment variables lazily. + + NOTE: After enable_envs_cache() invocation (which triggered after service + initialization), all environment variables will be cached. + """ + if name in environment_variables: + return environment_variables[name]() + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def _is_envs_cache_enabled() -> bool: + """Checked if __getattr__ is wrapped with functools.cache.""" + global __getattr__ + return hasattr(__getattr__, "cache_clear") + + +def enable_envs_cache() -> None: + """Enables caching of environment variables. This is useful for performance + reasons, as it avoids the need to re-evaluate environment variables on + every call. + + NOTE: Currently, it's invoked after service initialization to reduce + runtime overhead. This also means that environment variables should NOT + be updated after the service is initialized. + """ + if _is_envs_cache_enabled(): + # Avoid wrapping functools.cache multiple times + return + # Tag __getattr__ with functools.cache + global __getattr__ + __getattr__ = functools.cache(__getattr__) + + # Cache all environment variables + for key in environment_variables: + __getattr__(key) + + +def disable_envs_cache() -> None: + """Resets the environment variables cache. It could be used to isolate environments + between unit tests. + """ + global __getattr__ + # If __getattr__ is wrapped by functions.cache, unwrap the caching layer. + if _is_envs_cache_enabled(): + assert hasattr(__getattr__, "__wrapped__") + __getattr__ = __getattr__.__wrapped__ + + +def __dir__(): + return list(environment_variables.keys()) + + +def is_set(name: str): + """Check if an environment variable is explicitly set.""" + if name in environment_variables: + return name in os.environ + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def compile_factors() -> dict[str, object]: + """Return env vars used for torch.compile cache keys. + + Start with every known vLLM env var; drop entries in `ignored_factors`; + hash everything else. This keeps the cache key aligned across workers.""" + ignored_factors: set[str] = { + "MAX_JOBS", + "VLLM_TRITON_JIT_WARMUP_NUM_THREADS", + "VLLM_RPC_BASE_PATH", + "VLLM_USE_MODELSCOPE", + "VLLM_RINGBUFFER_WARNING_INTERVAL", + "VLLM_DEBUG_DUMP_PATH", + "VLLM_PORT", + "VLLM_CACHE_ROOT", + # Runtime memory-plan persistence; does not affect compiled graphs. + "VLLM_ENABLE_STARTUP_PLAN", + # Location-only derived paths: where a cache/config directory lives + # cannot affect compiled artifacts, and hashing them means relocating + # HOME or the XDG roots silently invalidates every compile cache + # (VLLM_CACHE_ROOT above and VLLM_FLASHINFER_AUTOTUNE_CACHE_DIR below + # are already ignored for the same reason). + "VLLM_XLA_CACHE_PATH", + "VLLM_CONFIG_ROOT", + "LD_LIBRARY_PATH", + "VLLM_SERVER_DEV_MODE", + "VLLM_DP_MASTER_IP", + "VLLM_DP_MASTER_PORT", + "VLLM_NIXL_SIDE_CHANNEL_HOST", + "VLLM_RANDOMIZE_DP_DUMMY_INPUTS", + "VLLM_MODEL_REDIRECT_PATH", + "VLLM_HOST_IP", + "VLLM_ELASTIC_EP_SCALE_UP_LAUNCH", + "VLLM_FORCE_AOT_LOAD", + "S3_ACCESS_KEY_ID", + "S3_SECRET_ACCESS_KEY", + "S3_ENDPOINT_URL", + # Credential; never affects compiled artifacts and must not be + # persisted in cache_key_factors.json. + "VLLM_API_KEY", + "VLLM_USAGE_STATS_SERVER", + "VLLM_NO_USAGE_STATS", + "VLLM_DO_NOT_TRACK", + "VLLM_LOGGING_LEVEL", + "VLLM_LOGGING_PREFIX", + "VLLM_LOGGING_STREAM", + "VLLM_LOGGING_CONFIG_PATH", + "VLLM_LOGGING_COLOR", + "VLLM_LOG_STATS_INTERVAL", + "VLLM_DEBUG_LOG_API_SERVER_RESPONSE", + "VLLM_TUNED_CONFIG_FOLDER", + "VLLM_FLASHINFER_AUTOTUNE_CACHE_DIR", + "VLLM_FLASHINFER_AUTOTUNE_SKIP_OPS", + "VLLM_HTTP_TIMEOUT_KEEP_ALIVE", + "VLLM_EXECUTE_MODEL_TIMEOUT_SECONDS", + "VLLM_WORKER_SHUTDOWN_TIMEOUT_SECONDS", + "VLLM_KEEP_ALIVE_ON_ENGINE_DEATH", + "VLLM_IMAGE_FETCH_TIMEOUT", + "VLLM_VIDEO_FETCH_TIMEOUT", + "VLLM_AUDIO_FETCH_TIMEOUT", + "VLLM_MEDIA_CACHE", + "VLLM_MEDIA_CACHE_MAX_SIZE_MB", + "VLLM_MEDIA_CACHE_TTL_HOURS", + "VLLM_MEDIA_FETCH_MAX_RETRIES", + "VLLM_MEDIA_URL_ALLOW_REDIRECTS", + "VLLM_MEDIA_LOADING_THREAD_COUNT", + "VLLM_MAX_AUDIO_CLIP_FILESIZE_MB", + "VLLM_MAX_AUDIO_DECODE_DURATION_S", + "VLLM_MAX_AUDIO_DECODE_BYTES", + "VLLM_MAX_AUDIO_PREPROCESS_WORKERS", + "VLLM_MAX_EMBED_DECODE_BYTES", + "VLLM_MAX_IMAGE_PIXELS", + "VLLM_VIDEO_LOADER_BACKEND", + "VLLM_MEDIA_CONNECTOR", + "VLLM_OBJECT_STORAGE_SHM_BUFFER_NAME", + "VLLM_ASSETS_CACHE", + "VLLM_ASSETS_CACHE_MODEL_CLEAN", + "VLLM_WORKER_MULTIPROC_METHOD", + "VLLM_ENABLE_V1_MULTIPROCESSING", + "VLLM_V1_OUTPUT_PROC_CHUNK_SIZE", + "VLLM_CPU_KVCACHE_SPACE", + "VLLM_CPU_MOE_PREPACK", + "VLLM_ZENTORCH_WEIGHT_PREPACK", + "VLLM_TEST_FORCE_LOAD_FORMAT", + "VLLM_ENABLE_CUDA_COMPATIBILITY", + "VLLM_CUDA_COMPATIBILITY_PATH", + "VLLM_SKIP_MODEL_NAME_VALIDATION", + "LOCAL_RANK", + "CUDA_VISIBLE_DEVICES", + "NO_COLOR", + "VLLM_SKIP_VERSION_SUFFIX", + "FORCE_COLOR", + } + + from vllm.config.utils import normalize_value + + factors: dict[str, object] = {} + for factor, getter in environment_variables.items(): + if factor in ignored_factors: + continue + + try: + raw = getter() + except Exception as exc: # pragma: no cover - defensive logging + logger.warning( + "Skipping environment variable %s while hashing compile factors: %s", + factor, + exc, + ) + continue + + factors[factor] = normalize_value(raw) + + ray_noset_env_vars = [ + # Refer to + # https://github.com/ray-project/ray/blob/c584b1ea97b00793d1def71eaf81537d70efba42/python/ray/_private/accelerators/nvidia_gpu.py#L11 + # https://github.com/ray-project/ray/blob/c584b1ea97b00793d1def71eaf81537d70efba42/python/ray/_private/accelerators/amd_gpu.py#L11 + # https://github.com/ray-project/ray/blob/b97d21dab233c2bd8ed7db749a82a1e594222b5c/python/ray/_private/accelerators/amd_gpu.py#L10 + # https://github.com/ray-project/ray/blob/c584b1ea97b00793d1def71eaf81537d70efba42/python/ray/_private/accelerators/npu.py#L12 + # https://github.com/ray-project/ray/blob/c584b1ea97b00793d1def71eaf81537d70efba42/python/ray/_private/accelerators/hpu.py#L12 + # https://github.com/ray-project/ray/blob/c584b1ea97b00793d1def71eaf81537d70efba42/python/ray/_private/accelerators/neuron.py#L14 + # https://github.com/ray-project/ray/blob/c584b1ea97b00793d1def71eaf81537d70efba42/python/ray/_private/accelerators/tpu.py#L38 + # https://github.com/ray-project/ray/blob/c584b1ea97b00793d1def71eaf81537d70efba42/python/ray/_private/accelerators/intel_gpu.py#L10 + # https://github.com/ray-project/ray/blob/c584b1ea97b00793d1def71eaf81537d70efba42/python/ray/_private/accelerators/rbln.py#L10 + "RAY_EXPERIMENTAL_NOSET_CUDA_VISIBLE_DEVICES", + "RAY_EXPERIMENTAL_NOSET_ROCR_VISIBLE_DEVICES", + "RAY_EXPERIMENTAL_NOSET_HIP_VISIBLE_DEVICES", + "RAY_EXPERIMENTAL_NOSET_ASCEND_RT_VISIBLE_DEVICES", + "RAY_EXPERIMENTAL_NOSET_HABANA_VISIBLE_MODULES", + "RAY_EXPERIMENTAL_NOSET_NEURON_RT_VISIBLE_CORES", + "RAY_EXPERIMENTAL_NOSET_TPU_VISIBLE_CHIPS", + "RAY_EXPERIMENTAL_NOSET_ONEAPI_DEVICE_SELECTOR", + "RAY_EXPERIMENTAL_NOSET_RBLN_RT_VISIBLE_DEVICES", + ] + + for var in ray_noset_env_vars: + factors[var] = normalize_value(os.getenv(var)) + + return factors diff --git a/tests/fixtures/dg2-a770-i915/facts.json b/tests/fixtures/dg2-a770-i915/facts.json index 5fb730a7..ca67ab92 100644 --- a/tests/fixtures/dg2-a770-i915/facts.json +++ b/tests/fixtures/dg2-a770-i915/facts.json @@ -288,8 +288,8 @@ "release": { "k3d_gpu_version": "<version>", "pins": { - "llama.cpp": "ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", - "ovms": "openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" + "llama.cpp": "ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", + "ovms": "openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" } }, "schema": "k3d-gpu.facts/v1", diff --git a/tests/fixtures/dg2-a770-i915/report.html b/tests/fixtures/dg2-a770-i915/report.html index 076b2472..134e9fd3 100644 --- a/tests/fixtures/dg2-a770-i915/report.html +++ b/tests/fixtures/dg2-a770-i915/report.html @@ -67,9 +67,9 @@ <h2>Detected</h2> <h2>Node labels</h2> <div class="wrap"><table><tbody><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512BF16</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512F</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512VNNI</th><td>true</td></tr><tr><th>gpu.intel.com/xmx</th><td>true</td></tr></tbody></table></div> <h2>Release pins</h2> -<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> +<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> <footer> -<p>Rendered from facts/v1, sha256 of its canonical bytes <code>a6da1f00739e5d05847c5b0c2eb579e51746510b28e1a3ae7cce7aaa3986672b</code>, and plan/v1.</p> +<p>Rendered from facts/v1, sha256 of its canonical bytes <code>a6d9142d067e033cadbf23097f11bc33b4dd654693459545ce809fd2fa2d13f1</code>, and plan/v1.</p> <p>This page runs no script and loads nothing: its Content-Security-Policy is <code>default-src 'none'</code>. The links are references to follow, not resources it fetches.</p> <p><a href="https://github.com/88plug/k3d-gpu" rel="noreferrer">https://github.com/88plug/k3d-gpu</a></p> </footer></main></body></html> diff --git a/tests/fixtures/flex140-unbound/facts.json b/tests/fixtures/flex140-unbound/facts.json index 11b628ef..ce059f3c 100644 --- a/tests/fixtures/flex140-unbound/facts.json +++ b/tests/fixtures/flex140-unbound/facts.json @@ -233,8 +233,8 @@ "release": { "k3d_gpu_version": "<version>", "pins": { - "llama.cpp": "ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", - "ovms": "openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" + "llama.cpp": "ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", + "ovms": "openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" } }, "schema": "k3d-gpu.facts/v1", diff --git a/tests/fixtures/flex140-unbound/report.html b/tests/fixtures/flex140-unbound/report.html index 7129f903..e71a0b93 100644 --- a/tests/fixtures/flex140-unbound/report.html +++ b/tests/fixtures/flex140-unbound/report.html @@ -67,9 +67,9 @@ <h2>Detected</h2> <h2>Node labels</h2> <div class="wrap"><table><tbody><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512BF16</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512F</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512VNNI</th><td>true</td></tr></tbody></table></div> <h2>Release pins</h2> -<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> +<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> <footer> -<p>Rendered from facts/v1, sha256 of its canonical bytes <code>c983e76aa4c8929ee6a71d53ddfc8948ac59b6fc37f793fdd64ca51d18e6e216</code>, and plan/v1.</p> +<p>Rendered from facts/v1, sha256 of its canonical bytes <code>fe892d98c00884c4a8ef0df72929bce8c8ca2f6a0fffef04e0bdebb011de1c1d</code>, and plan/v1.</p> <p>This page runs no script and loads nothing: its Content-Security-Policy is <code>default-src 'none'</code>. The links are references to follow, not resources it fetches.</p> <p><a href="https://github.com/88plug/k3d-gpu" rel="noreferrer">https://github.com/88plug/k3d-gpu</a></p> </footer></main></body></html> diff --git a/tests/fixtures/gaudi3-hls/facts.json b/tests/fixtures/gaudi3-hls/facts.json index 50e62ac8..df64ce9d 100644 --- a/tests/fixtures/gaudi3-hls/facts.json +++ b/tests/fixtures/gaudi3-hls/facts.json @@ -327,8 +327,8 @@ "release": { "k3d_gpu_version": "<version>", "pins": { - "llama.cpp": "ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", - "ovms": "openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" + "llama.cpp": "ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", + "ovms": "openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" } }, "schema": "k3d-gpu.facts/v1", diff --git a/tests/fixtures/gaudi3-hls/report.html b/tests/fixtures/gaudi3-hls/report.html index a7d864cd..3d4049e5 100644 --- a/tests/fixtures/gaudi3-hls/report.html +++ b/tests/fixtures/gaudi3-hls/report.html @@ -67,9 +67,9 @@ <h2>Detected</h2> <h2>Node labels</h2> <div class="wrap"><table><tbody><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXBF16</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXINT8</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXTILE</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512BF16</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512F</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512VNNI</th><td>true</td></tr></tbody></table></div> <h2>Release pins</h2> -<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> +<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> <footer> -<p>Rendered from facts/v1, sha256 of its canonical bytes <code>d65aee54a4011e536eeb8b16d997fa9d72363403f7e2c1c0ad07d4e41801032f</code>, and plan/v1.</p> +<p>Rendered from facts/v1, sha256 of its canonical bytes <code>bbc164e3895e64e1c9066b2f4a8452fa6aa204b422098996701948d88f75b07a</code>, and plan/v1.</p> <p>This page runs no script and loads nothing: its Content-Security-Policy is <code>default-src 'none'</code>. The links are references to follow, not resources it fetches.</p> <p><a href="https://github.com/88plug/k3d-gpu" rel="noreferrer">https://github.com/88plug/k3d-gpu</a></p> </footer></main></body></html> diff --git a/tests/fixtures/gaudi3-no-driver/facts.json b/tests/fixtures/gaudi3-no-driver/facts.json index 25ae844e..12088b8c 100644 --- a/tests/fixtures/gaudi3-no-driver/facts.json +++ b/tests/fixtures/gaudi3-no-driver/facts.json @@ -307,8 +307,8 @@ "release": { "k3d_gpu_version": "<version>", "pins": { - "llama.cpp": "ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", - "ovms": "openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" + "llama.cpp": "ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", + "ovms": "openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" } }, "schema": "k3d-gpu.facts/v1", diff --git a/tests/fixtures/gaudi3-no-driver/report.html b/tests/fixtures/gaudi3-no-driver/report.html index 5fc1fe7d..00bc0bf8 100644 --- a/tests/fixtures/gaudi3-no-driver/report.html +++ b/tests/fixtures/gaudi3-no-driver/report.html @@ -67,9 +67,9 @@ <h2>Detected</h2> <h2>Node labels</h2> <div class="wrap"><table><tbody><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXBF16</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXINT8</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXTILE</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512BF16</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512F</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512VNNI</th><td>true</td></tr></tbody></table></div> <h2>Release pins</h2> -<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> +<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> <footer> -<p>Rendered from facts/v1, sha256 of its canonical bytes <code>966833f96e2ede1ebed22a48779de01a462adfc3b037b09ea56b4f0699a9e159</code>, and plan/v1.</p> +<p>Rendered from facts/v1, sha256 of its canonical bytes <code>01a8a00a37f7cd1d4ad889c95910330ceb3dfd371a0cef33f528de6af52885b6</code>, and plan/v1.</p> <p>This page runs no script and loads nothing: its Content-Security-Policy is <code>default-src 'none'</code>. The links are references to follow, not resources it fetches.</p> <p><a href="https://github.com/88plug/k3d-gpu" rel="noreferrer">https://github.com/88plug/k3d-gpu</a></p> </footer></main></body></html> diff --git a/tests/fixtures/lunarlake-k6.8/facts.json b/tests/fixtures/lunarlake-k6.8/facts.json index fdef4a29..54ee8867 100644 --- a/tests/fixtures/lunarlake-k6.8/facts.json +++ b/tests/fixtures/lunarlake-k6.8/facts.json @@ -324,8 +324,8 @@ "release": { "k3d_gpu_version": "<version>", "pins": { - "llama.cpp": "ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", - "ovms": "openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" + "llama.cpp": "ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", + "ovms": "openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" } }, "schema": "k3d-gpu.facts/v1", diff --git a/tests/fixtures/lunarlake-k6.8/report.html b/tests/fixtures/lunarlake-k6.8/report.html index cdbd5047..312d03e2 100644 --- a/tests/fixtures/lunarlake-k6.8/report.html +++ b/tests/fixtures/lunarlake-k6.8/report.html @@ -67,9 +67,9 @@ <h2>Detected</h2> <h2>Node labels</h2> <div class="wrap"><table><tbody><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVXVNNI</th><td>true</td></tr><tr><th>k3d-gpu.io/hybrid-cpu</th><td>true</td></tr><tr><th>k3d-gpu.io/npu.gen</th><td>40xx</td></tr></tbody></table></div> <h2>Release pins</h2> -<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> +<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> <footer> -<p>Rendered from facts/v1, sha256 of its canonical bytes <code>668d338fa97bc522d22f9f4894e9172986f6b4b63619c1175c85f796a6291e55</code>, and plan/v1.</p> +<p>Rendered from facts/v1, sha256 of its canonical bytes <code>dfae7bb57ced7214194884a9768bcc777c0fd1306319485eea5a39dbbdb9b245</code>, and plan/v1.</p> <p>This page runs no script and loads nothing: its Content-Security-Policy is <code>default-src 'none'</code>. The links are references to follow, not resources it fetches.</p> <p><a href="https://github.com/88plug/k3d-gpu" rel="noreferrer">https://github.com/88plug/k3d-gpu</a></p> </footer></main></body></html> diff --git a/tests/fixtures/lunarlake/facts.json b/tests/fixtures/lunarlake/facts.json index 8c0e46f2..f252df0e 100644 --- a/tests/fixtures/lunarlake/facts.json +++ b/tests/fixtures/lunarlake/facts.json @@ -391,8 +391,8 @@ "release": { "k3d_gpu_version": "<version>", "pins": { - "llama.cpp": "ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", - "ovms": "openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" + "llama.cpp": "ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", + "ovms": "openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" } }, "schema": "k3d-gpu.facts/v1", diff --git a/tests/fixtures/lunarlake/report.html b/tests/fixtures/lunarlake/report.html index 76df00e1..c1d7d299 100644 --- a/tests/fixtures/lunarlake/report.html +++ b/tests/fixtures/lunarlake/report.html @@ -67,9 +67,9 @@ <h2>Detected</h2> <h2>Node labels</h2> <div class="wrap"><table><tbody><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVXVNNI</th><td>true</td></tr><tr><th>gpu.intel.com/xmx</th><td>true</td></tr><tr><th>k3d-gpu.io/hybrid-cpu</th><td>true</td></tr><tr><th>k3d-gpu.io/npu.gen</th><td>40xx</td></tr></tbody></table></div> <h2>Release pins</h2> -<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> +<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> <footer> -<p>Rendered from facts/v1, sha256 of its canonical bytes <code>6dad913789b0eb1ae10cfd3bf161eb4f53ed8d940817d6b7b04c40a4f7b91987</code>, and plan/v1.</p> +<p>Rendered from facts/v1, sha256 of its canonical bytes <code>f096c935cade5df6cda8396e12ff05988855227ced8d620ffa302505aef37f03</code>, and plan/v1.</p> <p>This page runs no script and loads nothing: its Content-Security-Policy is <code>default-src 'none'</code>. The links are references to follow, not resources it fetches.</p> <p><a href="https://github.com/88plug/k3d-gpu" rel="noreferrer">https://github.com/88plug/k3d-gpu</a></p> </footer></main></body></html> diff --git a/tests/fixtures/meteorlake/facts.json b/tests/fixtures/meteorlake/facts.json index 8298d157..b774d530 100644 --- a/tests/fixtures/meteorlake/facts.json +++ b/tests/fixtures/meteorlake/facts.json @@ -241,8 +241,8 @@ "release": { "k3d_gpu_version": "<version>", "pins": { - "llama.cpp": "ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", - "ovms": "openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" + "llama.cpp": "ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", + "ovms": "openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" } }, "schema": "k3d-gpu.facts/v1", diff --git a/tests/fixtures/meteorlake/report.html b/tests/fixtures/meteorlake/report.html index 6e5dcbf6..a1eae70d 100644 --- a/tests/fixtures/meteorlake/report.html +++ b/tests/fixtures/meteorlake/report.html @@ -67,9 +67,9 @@ <h2>Detected</h2> <h2>Node labels</h2> <div class="wrap"><table><tbody><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVXVNNI</th><td>true</td></tr><tr><th>k3d-gpu.io/hybrid-cpu</th><td>true</td></tr></tbody></table></div> <h2>Release pins</h2> -<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> +<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> <footer> -<p>Rendered from facts/v1, sha256 of its canonical bytes <code>5260ecb4bda99ec68bf1b2e9f63726d38b140d66ed4fa430322da3bada11e1a4</code>, and plan/v1.</p> +<p>Rendered from facts/v1, sha256 of its canonical bytes <code>52b3b8c246142445cff5399c86ff5322df56f11066f9dd3dd3db638db0c0935d</code>, and plan/v1.</p> <p>This page runs no script and loads nothing: its Content-Security-Policy is <code>default-src 'none'</code>. The links are references to follow, not resources it fetches.</p> <p><a href="https://github.com/88plug/k3d-gpu" rel="noreferrer">https://github.com/88plug/k3d-gpu</a></p> </footer></main></body></html> diff --git a/tests/fixtures/novalake/facts.json b/tests/fixtures/novalake/facts.json index 31d1d456..a02f37d0 100644 --- a/tests/fixtures/novalake/facts.json +++ b/tests/fixtures/novalake/facts.json @@ -255,8 +255,8 @@ "release": { "k3d_gpu_version": "<version>", "pins": { - "llama.cpp": "ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", - "ovms": "openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" + "llama.cpp": "ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", + "ovms": "openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" } }, "schema": "k3d-gpu.facts/v1", diff --git a/tests/fixtures/novalake/report.html b/tests/fixtures/novalake/report.html index 62248b68..50c9bd0d 100644 --- a/tests/fixtures/novalake/report.html +++ b/tests/fixtures/novalake/report.html @@ -67,9 +67,9 @@ <h2>Detected</h2> <h2>Node labels</h2> <div class="wrap"><table><tbody><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVXVNNI</th><td>true</td></tr><tr><th>k3d-gpu.io/hybrid-cpu</th><td>true</td></tr></tbody></table></div> <h2>Release pins</h2> -<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> +<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> <footer> -<p>Rendered from facts/v1, sha256 of its canonical bytes <code>cac5ad3082233ecad4b069c780e046f78b273b5921f4df98667ccf8a49bb2a2f</code>, and plan/v1.</p> +<p>Rendered from facts/v1, sha256 of its canonical bytes <code>ca0f14dec060ec358a7c030c94c142ce1a325fea6a844221087de1be7520cfab</code>, and plan/v1.</p> <p>This page runs no script and loads nothing: its Content-Security-Policy is <code>default-src 'none'</code>. The links are references to follow, not resources it fetches.</p> <p><a href="https://github.com/88plug/k3d-gpu" rel="noreferrer">https://github.com/88plug/k3d-gpu</a></p> </footer></main></body></html> diff --git a/tests/fixtures/npu-unserved/facts.json b/tests/fixtures/npu-unserved/facts.json index a32050ff..1c9e2756 100644 --- a/tests/fixtures/npu-unserved/facts.json +++ b/tests/fixtures/npu-unserved/facts.json @@ -399,8 +399,8 @@ "release": { "k3d_gpu_version": "<version>", "pins": { - "llama.cpp": "ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", - "ovms": "openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" + "llama.cpp": "ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", + "ovms": "openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" } }, "schema": "k3d-gpu.facts/v1", diff --git a/tests/fixtures/npu-unserved/report.html b/tests/fixtures/npu-unserved/report.html index 0e262939..78b96bf5 100644 --- a/tests/fixtures/npu-unserved/report.html +++ b/tests/fixtures/npu-unserved/report.html @@ -67,9 +67,9 @@ <h2>Detected</h2> <h2>Node labels</h2> <div class="wrap"><table><tbody><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVXVNNI</th><td>true</td></tr><tr><th>gpu.intel.com/xmx</th><td>true</td></tr><tr><th>k3d-gpu.io/hybrid-cpu</th><td>true</td></tr></tbody></table></div> <h2>Release pins</h2> -<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> +<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> <footer> -<p>Rendered from facts/v1, sha256 of its canonical bytes <code>e28d58bbb5d22637ec1745bc1341da9fb24e2bdfb60dc70318a4f251565083a7</code>, and plan/v1.</p> +<p>Rendered from facts/v1, sha256 of its canonical bytes <code>5a3fe962d31ce27f3942ff3ae1c5de9c3e21c28ff7a620a8cf962d273429c70c</code>, and plan/v1.</p> <p>This page runs no script and loads nothing: its Content-Security-Policy is <code>default-src 'none'</code>. The links are references to follow, not resources it fetches.</p> <p><a href="https://github.com/88plug/k3d-gpu" rel="noreferrer">https://github.com/88plug/k3d-gpu</a></p> </footer></main></body></html> diff --git a/tests/fixtures/pantherlake-k6.14/facts.json b/tests/fixtures/pantherlake-k6.14/facts.json index 9b6d2d87..89f4ac0e 100644 --- a/tests/fixtures/pantherlake-k6.14/facts.json +++ b/tests/fixtures/pantherlake-k6.14/facts.json @@ -333,8 +333,8 @@ "release": { "k3d_gpu_version": "<version>", "pins": { - "llama.cpp": "ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", - "ovms": "openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" + "llama.cpp": "ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", + "ovms": "openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" } }, "schema": "k3d-gpu.facts/v1", diff --git a/tests/fixtures/pantherlake-k6.14/report.html b/tests/fixtures/pantherlake-k6.14/report.html index 56a9665e..931514cc 100644 --- a/tests/fixtures/pantherlake-k6.14/report.html +++ b/tests/fixtures/pantherlake-k6.14/report.html @@ -67,9 +67,9 @@ <h2>Detected</h2> <h2>Node labels</h2> <div class="wrap"><table><tbody><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVXVNNI</th><td>true</td></tr><tr><th>k3d-gpu.io/hybrid-cpu</th><td>true</td></tr><tr><th>k3d-gpu.io/npu.gen</th><td>50xx</td></tr></tbody></table></div> <h2>Release pins</h2> -<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> +<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> <footer> -<p>Rendered from facts/v1, sha256 of its canonical bytes <code>6f47e914244eb2826ee5b52720ab279f6a388cf90e7fff5d53720cd07500e5e7</code>, and plan/v1.</p> +<p>Rendered from facts/v1, sha256 of its canonical bytes <code>55ba54ab0682106a92da68a7415fffa8fd462b26d8ab4457c135ec42f69f7525</code>, and plan/v1.</p> <p>This page runs no script and loads nothing: its Content-Security-Policy is <code>default-src 'none'</code>. The links are references to follow, not resources it fetches.</p> <p><a href="https://github.com/88plug/k3d-gpu" rel="noreferrer">https://github.com/88plug/k3d-gpu</a></p> </footer></main></body></html> diff --git a/tests/fixtures/pantherlake/facts.json b/tests/fixtures/pantherlake/facts.json index 50ee9b46..c08b37b3 100644 --- a/tests/fixtures/pantherlake/facts.json +++ b/tests/fixtures/pantherlake/facts.json @@ -424,8 +424,8 @@ "release": { "k3d_gpu_version": "<version>", "pins": { - "llama.cpp": "ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", - "ovms": "openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" + "llama.cpp": "ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", + "ovms": "openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" } }, "schema": "k3d-gpu.facts/v1", diff --git a/tests/fixtures/pantherlake/report.html b/tests/fixtures/pantherlake/report.html index 9e4d05ab..851e5d8d 100644 --- a/tests/fixtures/pantherlake/report.html +++ b/tests/fixtures/pantherlake/report.html @@ -67,9 +67,9 @@ <h2>Detected</h2> <h2>Node labels</h2> <div class="wrap"><table><tbody><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVXVNNI</th><td>true</td></tr><tr><th>gpu.intel.com/xmx</th><td>true</td></tr><tr><th>k3d-gpu.io/hybrid-cpu</th><td>true</td></tr><tr><th>k3d-gpu.io/npu.gen</th><td>50xx</td></tr></tbody></table></div> <h2>Release pins</h2> -<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> +<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> <footer> -<p>Rendered from facts/v1, sha256 of its canonical bytes <code>996be87a6dd3893eb0f3480f94908259a065a7bd013ae42a58b0a4733464e975</code>, and plan/v1.</p> +<p>Rendered from facts/v1, sha256 of its canonical bytes <code>40bf73af9d959da029449659c0ed2054d9cb58e95b2bbb375a33b1db7eb99ffe</code>, and plan/v1.</p> <p>This page runs no script and loads nothing: its Content-Security-Policy is <code>default-src 'none'</code>. The links are references to follow, not resources it fetches.</p> <p><a href="https://github.com/88plug/k3d-gpu" rel="noreferrer">https://github.com/88plug/k3d-gpu</a></p> </footer></main></body></html> diff --git a/tests/fixtures/pvc-max1550-unbound/facts.json b/tests/fixtures/pvc-max1550-unbound/facts.json index 9e8af6fb..b56544ef 100644 --- a/tests/fixtures/pvc-max1550-unbound/facts.json +++ b/tests/fixtures/pvc-max1550-unbound/facts.json @@ -233,8 +233,8 @@ "release": { "k3d_gpu_version": "<version>", "pins": { - "llama.cpp": "ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", - "ovms": "openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" + "llama.cpp": "ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", + "ovms": "openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" } }, "schema": "k3d-gpu.facts/v1", diff --git a/tests/fixtures/pvc-max1550-unbound/report.html b/tests/fixtures/pvc-max1550-unbound/report.html index 819c6e1f..d2055c4a 100644 --- a/tests/fixtures/pvc-max1550-unbound/report.html +++ b/tests/fixtures/pvc-max1550-unbound/report.html @@ -67,9 +67,9 @@ <h2>Detected</h2> <h2>Node labels</h2> <div class="wrap"><table><tbody><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512BF16</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512F</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512VNNI</th><td>true</td></tr></tbody></table></div> <h2>Release pins</h2> -<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> +<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> <footer> -<p>Rendered from facts/v1, sha256 of its canonical bytes <code>c83b978ce6ddd252191e63c6ea1df8c1455fed1ba485a0d8a6d14b78398a4be5</code>, and plan/v1.</p> +<p>Rendered from facts/v1, sha256 of its canonical bytes <code>f5750c1f5808ab83092c439b7d0ac4f3792354191daee7741d9a374c4eab3c0e</code>, and plan/v1.</p> <p>This page runs no script and loads nothing: its Content-Security-Policy is <code>default-src 'none'</code>. The links are references to follow, not resources it fetches.</p> <p><a href="https://github.com/88plug/k3d-gpu" rel="noreferrer">https://github.com/88plug/k3d-gpu</a></p> </footer></main></body></html> diff --git a/tests/fixtures/qat-420xx/facts.json b/tests/fixtures/qat-420xx/facts.json index b7a29ed2..4d4d66ab 100644 --- a/tests/fixtures/qat-420xx/facts.json +++ b/tests/fixtures/qat-420xx/facts.json @@ -301,8 +301,8 @@ "release": { "k3d_gpu_version": "<version>", "pins": { - "llama.cpp": "ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", - "ovms": "openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" + "llama.cpp": "ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", + "ovms": "openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" } }, "schema": "k3d-gpu.facts/v1", diff --git a/tests/fixtures/qat-420xx/report.html b/tests/fixtures/qat-420xx/report.html index 5f392888..b739860f 100644 --- a/tests/fixtures/qat-420xx/report.html +++ b/tests/fixtures/qat-420xx/report.html @@ -67,9 +67,9 @@ <h2>Detected</h2> <h2>Node labels</h2> <div class="wrap"><table><tbody><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXBF16</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXINT8</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXTILE</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512BF16</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512F</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512VNNI</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVXVNNI</th><td>true</td></tr></tbody></table></div> <h2>Release pins</h2> -<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> +<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> <footer> -<p>Rendered from facts/v1, sha256 of its canonical bytes <code>69cda846109fa15861ae5e640ba9c0a7f2b165b9e58c86d3223a179e38bc5e2c</code>, and plan/v1.</p> +<p>Rendered from facts/v1, sha256 of its canonical bytes <code>130f537378904603987e7b41b24350e61d7f2b56bf9cc8ca0457b5c622200688</code>, and plan/v1.</p> <p>This page runs no script and loads nothing: its Content-Security-Policy is <code>default-src 'none'</code>. The links are references to follow, not resources it fetches.</p> <p><a href="https://github.com/88plug/k3d-gpu" rel="noreferrer">https://github.com/88plug/k3d-gpu</a></p> </footer></main></body></html> diff --git a/tests/fixtures/raptorlake-nvidia/facts.json b/tests/fixtures/raptorlake-nvidia/facts.json index 5c048252..e7512d79 100644 --- a/tests/fixtures/raptorlake-nvidia/facts.json +++ b/tests/fixtures/raptorlake-nvidia/facts.json @@ -937,8 +937,8 @@ "release": { "k3d_gpu_version": "<version>", "pins": { - "llama.cpp": "ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", - "ovms": "openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" + "llama.cpp": "ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", + "ovms": "openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" } }, "schema": "k3d-gpu.facts/v1", diff --git a/tests/fixtures/raptorlake-nvidia/report.html b/tests/fixtures/raptorlake-nvidia/report.html index b7eb7297..b70e8a4f 100644 --- a/tests/fixtures/raptorlake-nvidia/report.html +++ b/tests/fixtures/raptorlake-nvidia/report.html @@ -67,9 +67,9 @@ <h2>Detected</h2> <h2>Node labels</h2> <div class="wrap"><table><tbody><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVXVNNI</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.CETIBT</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.CETSS</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.GFNI</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.SHA</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.VAES</th><td>true</td></tr><tr><th>gpu.intel.com/media-vcs</th><td>2</td></tr><tr><th>gpu.intel.com/media-vecs</th><td>1</td></tr><tr><th>gpu.intel.com/xmx</th><td>false</td></tr><tr><th>k3d-gpu.io/hybrid-cpu</th><td>true</td></tr></tbody></table></div> <h2>Release pins</h2> -<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> +<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> <footer> -<p>Rendered from facts/v1, sha256 of its canonical bytes <code>054c57a66f59acbf44280b23d693d17a634b1a9ab433ea016de6812ffd8909da</code>, and plan/v1.</p> +<p>Rendered from facts/v1, sha256 of its canonical bytes <code>8a92631ab5d6f1a56235ddda444ca29625889d0ffc494f0fd8f617c060aba869</code>, and plan/v1.</p> <p>This page runs no script and loads nothing: its Content-Security-Policy is <code>default-src 'none'</code>. The links are references to follow, not resources it fetches.</p> <p><a href="https://github.com/88plug/k3d-gpu" rel="noreferrer">https://github.com/88plug/k3d-gpu</a></p> </footer></main></body></html> diff --git a/tests/fixtures/raptorlake-stale-guc/facts.json b/tests/fixtures/raptorlake-stale-guc/facts.json index 3f09d63a..c8cc730d 100644 --- a/tests/fixtures/raptorlake-stale-guc/facts.json +++ b/tests/fixtures/raptorlake-stale-guc/facts.json @@ -943,8 +943,8 @@ "release": { "k3d_gpu_version": "<version>", "pins": { - "llama.cpp": "ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", - "ovms": "openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" + "llama.cpp": "ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", + "ovms": "openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" } }, "schema": "k3d-gpu.facts/v1", diff --git a/tests/fixtures/raptorlake-stale-guc/report.html b/tests/fixtures/raptorlake-stale-guc/report.html index 61a0710c..4ea32aea 100644 --- a/tests/fixtures/raptorlake-stale-guc/report.html +++ b/tests/fixtures/raptorlake-stale-guc/report.html @@ -67,9 +67,9 @@ <h2>Detected</h2> <h2>Node labels</h2> <div class="wrap"><table><tbody><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVXVNNI</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.CETIBT</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.CETSS</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.GFNI</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.SHA</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.VAES</th><td>true</td></tr><tr><th>gpu.intel.com/media-vcs</th><td>2</td></tr><tr><th>gpu.intel.com/media-vecs</th><td>1</td></tr><tr><th>gpu.intel.com/xmx</th><td>false</td></tr><tr><th>k3d-gpu.io/hybrid-cpu</th><td>true</td></tr></tbody></table></div> <h2>Release pins</h2> -<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> +<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> <footer> -<p>Rendered from facts/v1, sha256 of its canonical bytes <code>49896a2c68669c84b35febc66b3c9735917e1344d96ad42ea06855f87c6b1377</code>, and plan/v1.</p> +<p>Rendered from facts/v1, sha256 of its canonical bytes <code>7bbafa05717868924d5e68b0ae240a2efe5ab4fb09fa73b48fe27f231e67f740</code>, and plan/v1.</p> <p>This page runs no script and loads nothing: its Content-Security-Policy is <code>default-src 'none'</code>. The links are references to follow, not resources it fetches.</p> <p><a href="https://github.com/88plug/k3d-gpu" rel="noreferrer">https://github.com/88plug/k3d-gpu</a></p> </footer></main></body></html> diff --git a/tests/fixtures/wildcatlake/facts.json b/tests/fixtures/wildcatlake/facts.json index 60d1aff5..9e35a9b3 100644 --- a/tests/fixtures/wildcatlake/facts.json +++ b/tests/fixtures/wildcatlake/facts.json @@ -324,8 +324,8 @@ "release": { "k3d_gpu_version": "<version>", "pins": { - "llama.cpp": "ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", - "ovms": "openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" + "llama.cpp": "ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", + "ovms": "openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" } }, "schema": "k3d-gpu.facts/v1", diff --git a/tests/fixtures/wildcatlake/report.html b/tests/fixtures/wildcatlake/report.html index 93b338d3..1b6a7ddd 100644 --- a/tests/fixtures/wildcatlake/report.html +++ b/tests/fixtures/wildcatlake/report.html @@ -67,9 +67,9 @@ <h2>Detected</h2> <h2>Node labels</h2> <div class="wrap"><table><tbody><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVXVNNI</th><td>true</td></tr><tr><th>k3d-gpu.io/hybrid-cpu</th><td>true</td></tr><tr><th>k3d-gpu.io/npu.gen</th><td>50xx</td></tr></tbody></table></div> <h2>Release pins</h2> -<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> +<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> <footer> -<p>Rendered from facts/v1, sha256 of its canonical bytes <code>7df3056a544fc9016caf3591f3cfe7b7c6ffae5519eb37bc53e8961a54706252</code>, and plan/v1.</p> +<p>Rendered from facts/v1, sha256 of its canonical bytes <code>16c68833adddf2734068c678805419b819deaed384b0598edd2f5602b682b721</code>, and plan/v1.</p> <p>This page runs no script and loads nothing: its Content-Security-Policy is <code>default-src 'none'</code>. The links are references to follow, not resources it fetches.</p> <p><a href="https://github.com/88plug/k3d-gpu" rel="noreferrer">https://github.com/88plug/k3d-gpu</a></p> </footer></main></body></html> diff --git a/tests/fixtures/xeon-6741p/facts.json b/tests/fixtures/xeon-6741p/facts.json index e9b7a53d..8641f05c 100644 --- a/tests/fixtures/xeon-6741p/facts.json +++ b/tests/fixtures/xeon-6741p/facts.json @@ -700,8 +700,8 @@ "release": { "k3d_gpu_version": "<version>", "pins": { - "llama.cpp": "ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", - "ovms": "openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" + "llama.cpp": "ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", + "ovms": "openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" } }, "schema": "k3d-gpu.facts/v1", diff --git a/tests/fixtures/xeon-6741p/report.html b/tests/fixtures/xeon-6741p/report.html index 1e949e2b..b614fa92 100644 --- a/tests/fixtures/xeon-6741p/report.html +++ b/tests/fixtures/xeon-6741p/report.html @@ -67,9 +67,9 @@ <h2>Detected</h2> <h2>Node labels</h2> <div class="wrap"><table><tbody><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXBF16</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXFP16</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXINT8</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXTILE</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512BF16</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512F</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512VNNI</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVXVNNI</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.CETIBT</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.CETSS</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.GFNI</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.SHA</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.VAES</th><td>true</td></tr></tbody></table></div> <h2>Release pins</h2> -<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> +<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> <footer> -<p>Rendered from facts/v1, sha256 of its canonical bytes <code>60ec986919cd95d11ba63f06ba046f83e5aadaba18131fd1fa8afcf64dffb295</code>, and plan/v1.</p> +<p>Rendered from facts/v1, sha256 of its canonical bytes <code>a39b2508326465278d26c28571ec420c579ca0ade89e110ddded9a0e1e83c1cd</code>, and plan/v1.</p> <p>This page runs no script and loads nothing: its Content-Security-Policy is <code>default-src 'none'</code>. The links are references to follow, not resources it fetches.</p> <p><a href="https://github.com/88plug/k3d-gpu" rel="noreferrer">https://github.com/88plug/k3d-gpu</a></p> </footer></main></body></html> diff --git a/tests/fixtures/xeon-dmr/facts.json b/tests/fixtures/xeon-dmr/facts.json index 73e79d8f..7b23e33e 100644 --- a/tests/fixtures/xeon-dmr/facts.json +++ b/tests/fixtures/xeon-dmr/facts.json @@ -357,8 +357,8 @@ "release": { "k3d_gpu_version": "<version>", "pins": { - "llama.cpp": "ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", - "ovms": "openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" + "llama.cpp": "ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", + "ovms": "openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" } }, "schema": "k3d-gpu.facts/v1", diff --git a/tests/fixtures/xeon-dmr/report.html b/tests/fixtures/xeon-dmr/report.html index e47fa548..517ce512 100644 --- a/tests/fixtures/xeon-dmr/report.html +++ b/tests/fixtures/xeon-dmr/report.html @@ -67,9 +67,9 @@ <h2>Detected</h2> <h2>Node labels</h2> <div class="wrap"><table><tbody><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXBF16</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXCOMPLEX</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXFP16</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXINT8</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXTILE</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512BF16</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512F</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512VNNI</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVXVNNI</th><td>true</td></tr><tr><th>k3d-gpu.io/numa-nodes</th><td>6</td></tr><tr><th>k3d-gpu.io/snc</th><td>3</td></tr></tbody></table></div> <h2>Release pins</h2> -<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> +<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> <footer> -<p>Rendered from facts/v1, sha256 of its canonical bytes <code>cda4f02db0079e13cec29eb5fcaf42f2097d3697cd5e8aee855f3052ddacf2e2</code>, and plan/v1.</p> +<p>Rendered from facts/v1, sha256 of its canonical bytes <code>b61e0e9220b03e588f201ae54b1019d72bd3121ea4e50374abf7356c104abdb9</code>, and plan/v1.</p> <p>This page runs no script and loads nothing: its Content-Security-Policy is <code>default-src 'none'</code>. The links are references to follow, not resources it fetches.</p> <p><a href="https://github.com/88plug/k3d-gpu" rel="noreferrer">https://github.com/88plug/k3d-gpu</a></p> </footer></main></body></html> diff --git a/tests/fixtures/xeon-gnr/facts.json b/tests/fixtures/xeon-gnr/facts.json index bf3a407f..1dc6a79b 100644 --- a/tests/fixtures/xeon-gnr/facts.json +++ b/tests/fixtures/xeon-gnr/facts.json @@ -341,8 +341,8 @@ "release": { "k3d_gpu_version": "<version>", "pins": { - "llama.cpp": "ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", - "ovms": "openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" + "llama.cpp": "ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", + "ovms": "openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" } }, "schema": "k3d-gpu.facts/v1", diff --git a/tests/fixtures/xeon-gnr/report.html b/tests/fixtures/xeon-gnr/report.html index 2a593561..1551373c 100644 --- a/tests/fixtures/xeon-gnr/report.html +++ b/tests/fixtures/xeon-gnr/report.html @@ -67,9 +67,9 @@ <h2>Detected</h2> <h2>Node labels</h2> <div class="wrap"><table><tbody><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXBF16</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXFP16</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXINT8</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXTILE</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512BF16</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512F</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512VNNI</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVXVNNI</th><td>true</td></tr><tr><th>k3d-gpu.io/numa-nodes</th><td>6</td></tr><tr><th>k3d-gpu.io/snc</th><td>3</td></tr></tbody></table></div> <h2>Release pins</h2> -<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> +<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> <footer> -<p>Rendered from facts/v1, sha256 of its canonical bytes <code>922bb3ba1b40901aad7237fbdefc6fa9e2bf084813ee38858255d60084cb9e3f</code>, and plan/v1.</p> +<p>Rendered from facts/v1, sha256 of its canonical bytes <code>2d02c65b8ecc0936f170fb4575b6691284f141594b094c41ce88b41a4d9d3d1e</code>, and plan/v1.</p> <p>This page runs no script and loads nothing: its Content-Security-Policy is <code>default-src 'none'</code>. The links are references to follow, not resources it fetches.</p> <p><a href="https://github.com/88plug/k3d-gpu" rel="noreferrer">https://github.com/88plug/k3d-gpu</a></p> </footer></main></body></html> diff --git a/tests/fixtures/xeon-gnrd/facts.json b/tests/fixtures/xeon-gnrd/facts.json index 742481e6..420b201c 100644 --- a/tests/fixtures/xeon-gnrd/facts.json +++ b/tests/fixtures/xeon-gnrd/facts.json @@ -283,8 +283,8 @@ "release": { "k3d_gpu_version": "<version>", "pins": { - "llama.cpp": "ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", - "ovms": "openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" + "llama.cpp": "ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", + "ovms": "openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" } }, "schema": "k3d-gpu.facts/v1", diff --git a/tests/fixtures/xeon-gnrd/report.html b/tests/fixtures/xeon-gnrd/report.html index acbdf474..3dfbe8d2 100644 --- a/tests/fixtures/xeon-gnrd/report.html +++ b/tests/fixtures/xeon-gnrd/report.html @@ -67,9 +67,9 @@ <h2>Detected</h2> <h2>Node labels</h2> <div class="wrap"><table><tbody><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXBF16</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXCOMPLEX</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXFP16</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXINT8</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXTILE</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512BF16</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512F</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512VNNI</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVXVNNI</th><td>true</td></tr></tbody></table></div> <h2>Release pins</h2> -<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> +<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> <footer> -<p>Rendered from facts/v1, sha256 of its canonical bytes <code>f85133b66c65ba456cfa2e54ffebb478b900b5fde57bcf0b88a45897c2f66d30</code>, and plan/v1.</p> +<p>Rendered from facts/v1, sha256 of its canonical bytes <code>f0bb962cb22f61a68bb3c603ed64a8572d55991b48f216809ad2b570500ce16a</code>, and plan/v1.</p> <p>This page runs no script and loads nothing: its Content-Security-Policy is <code>default-src 'none'</code>. The links are references to follow, not resources it fetches.</p> <p><a href="https://github.com/88plug/k3d-gpu" rel="noreferrer">https://github.com/88plug/k3d-gpu</a></p> </footer></main></body></html> diff --git a/tests/fixtures/xeon-max-hbm-cache/facts.json b/tests/fixtures/xeon-max-hbm-cache/facts.json index ec317f91..97a657bb 100644 --- a/tests/fixtures/xeon-max-hbm-cache/facts.json +++ b/tests/fixtures/xeon-max-hbm-cache/facts.json @@ -289,8 +289,8 @@ "release": { "k3d_gpu_version": "<version>", "pins": { - "llama.cpp": "ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", - "ovms": "openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" + "llama.cpp": "ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", + "ovms": "openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" } }, "schema": "k3d-gpu.facts/v1", diff --git a/tests/fixtures/xeon-max-hbm-cache/report.html b/tests/fixtures/xeon-max-hbm-cache/report.html index ca45f5c0..9f1af6b7 100644 --- a/tests/fixtures/xeon-max-hbm-cache/report.html +++ b/tests/fixtures/xeon-max-hbm-cache/report.html @@ -67,9 +67,9 @@ <h2>Detected</h2> <h2>Node labels</h2> <div class="wrap"><table><tbody><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXBF16</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXINT8</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXTILE</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512BF16</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512F</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512VNNI</th><td>true</td></tr><tr><th>k3d-gpu.io/numa-nodes</th><td>4</td></tr><tr><th>k3d-gpu.io/snc</th><td>4</td></tr></tbody></table></div> <h2>Release pins</h2> -<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> +<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> <footer> -<p>Rendered from facts/v1, sha256 of its canonical bytes <code>e91bd3cfb1e9fbe450ee21042931894e3f99659092ebb1cd615b8bdc0071995a</code>, and plan/v1.</p> +<p>Rendered from facts/v1, sha256 of its canonical bytes <code>d4d51a38a63e6a24ee95c8c87fcfcefcebdb2ba9d2f5222c5adae42481e0479d</code>, and plan/v1.</p> <p>This page runs no script and loads nothing: its Content-Security-Policy is <code>default-src 'none'</code>. The links are references to follow, not resources it fetches.</p> <p><a href="https://github.com/88plug/k3d-gpu" rel="noreferrer">https://github.com/88plug/k3d-gpu</a></p> </footer></main></body></html> diff --git a/tests/fixtures/xeon-max-hbm-devdax/facts.json b/tests/fixtures/xeon-max-hbm-devdax/facts.json index 0b1d1a25..1ef147ad 100644 --- a/tests/fixtures/xeon-max-hbm-devdax/facts.json +++ b/tests/fixtures/xeon-max-hbm-devdax/facts.json @@ -280,8 +280,8 @@ "release": { "k3d_gpu_version": "<version>", "pins": { - "llama.cpp": "ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", - "ovms": "openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" + "llama.cpp": "ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", + "ovms": "openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" } }, "schema": "k3d-gpu.facts/v1", diff --git a/tests/fixtures/xeon-max-hbm-devdax/report.html b/tests/fixtures/xeon-max-hbm-devdax/report.html index 12534665..d0a254d7 100644 --- a/tests/fixtures/xeon-max-hbm-devdax/report.html +++ b/tests/fixtures/xeon-max-hbm-devdax/report.html @@ -67,9 +67,9 @@ <h2>Detected</h2> <h2>Node labels</h2> <div class="wrap"><table><tbody><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXBF16</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXINT8</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXTILE</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512BF16</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512F</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512VNNI</th><td>true</td></tr><tr><th>k3d-gpu.io/numa-nodes</th><td>4</td></tr><tr><th>k3d-gpu.io/snc</th><td>4</td></tr></tbody></table></div> <h2>Release pins</h2> -<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> +<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> <footer> -<p>Rendered from facts/v1, sha256 of its canonical bytes <code>3c25569f4da4ea55051255d0126460d91ccefb9a433edbfcfca7ec129fd85589</code>, and plan/v1.</p> +<p>Rendered from facts/v1, sha256 of its canonical bytes <code>f55ed5113868a06c3c470168045fa839e713f32236f751885f51e771ae9983cc</code>, and plan/v1.</p> <p>This page runs no script and loads nothing: its Content-Security-Policy is <code>default-src 'none'</code>. The links are references to follow, not resources it fetches.</p> <p><a href="https://github.com/88plug/k3d-gpu" rel="noreferrer">https://github.com/88plug/k3d-gpu</a></p> </footer></main></body></html> diff --git a/tests/fixtures/xeon-max-hbm/facts.json b/tests/fixtures/xeon-max-hbm/facts.json index df2a2584..dfc4a61e 100644 --- a/tests/fixtures/xeon-max-hbm/facts.json +++ b/tests/fixtures/xeon-max-hbm/facts.json @@ -280,8 +280,8 @@ "release": { "k3d_gpu_version": "<version>", "pins": { - "llama.cpp": "ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", - "ovms": "openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" + "llama.cpp": "ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", + "ovms": "openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" } }, "schema": "k3d-gpu.facts/v1", diff --git a/tests/fixtures/xeon-max-hbm/report.html b/tests/fixtures/xeon-max-hbm/report.html index b4d9222f..aef54b95 100644 --- a/tests/fixtures/xeon-max-hbm/report.html +++ b/tests/fixtures/xeon-max-hbm/report.html @@ -67,9 +67,9 @@ <h2>Detected</h2> <h2>Node labels</h2> <div class="wrap"><table><tbody><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXBF16</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXINT8</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXTILE</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512BF16</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512F</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512VNNI</th><td>true</td></tr><tr><th>k3d-gpu.io/numa-nodes</th><td>8</td></tr><tr><th>k3d-gpu.io/snc</th><td>4</td></tr></tbody></table></div> <h2>Release pins</h2> -<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> +<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> <footer> -<p>Rendered from facts/v1, sha256 of its canonical bytes <code>652e964a1a73480e6c1d2b64e770173433890efe380a4637590fb35918a9540f</code>, and plan/v1.</p> +<p>Rendered from facts/v1, sha256 of its canonical bytes <code>5da89e8f3ff16239433d1a8870f863c37dd1390ec27ce45231f3715a28f84110</code>, and plan/v1.</p> <p>This page runs no script and loads nothing: its Content-Security-Policy is <code>default-src 'none'</code>. The links are references to follow, not resources it fetches.</p> <p><a href="https://github.com/88plug/k3d-gpu" rel="noreferrer">https://github.com/88plug/k3d-gpu</a></p> </footer></main></body></html> diff --git a/tests/fixtures/xeon-spr-accel/facts.json b/tests/fixtures/xeon-spr-accel/facts.json index fc30d70c..49b2180c 100644 --- a/tests/fixtures/xeon-spr-accel/facts.json +++ b/tests/fixtures/xeon-spr-accel/facts.json @@ -350,8 +350,8 @@ "release": { "k3d_gpu_version": "<version>", "pins": { - "llama.cpp": "ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", - "ovms": "openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" + "llama.cpp": "ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", + "ovms": "openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" } }, "schema": "k3d-gpu.facts/v1", diff --git a/tests/fixtures/xeon-spr-accel/report.html b/tests/fixtures/xeon-spr-accel/report.html index caaaeeac..e4512d0e 100644 --- a/tests/fixtures/xeon-spr-accel/report.html +++ b/tests/fixtures/xeon-spr-accel/report.html @@ -67,9 +67,9 @@ <h2>Detected</h2> <h2>Node labels</h2> <div class="wrap"><table><tbody><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXBF16</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXINT8</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AMXTILE</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512BF16</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512F</th><td>true</td></tr><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVX512VNNI</th><td>true</td></tr><tr><th>k3d-gpu.io/numa-nodes</th><td>2</td></tr><tr><th>k3d-gpu.io/snc</th><td>1</td></tr></tbody></table></div> <h2>Release pins</h2> -<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> +<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> <footer> -<p>Rendered from facts/v1, sha256 of its canonical bytes <code>d575e4efe5d42a9f117402d4ba92c9408fca8aa178ee492fe7a00068fbb4190f</code>, and plan/v1.</p> +<p>Rendered from facts/v1, sha256 of its canonical bytes <code>dbed407a6bf091e750310daa94c35a7c5cf59d51a071c87d1a6b25a31a27f9d2</code>, and plan/v1.</p> <p>This page runs no script and loads nothing: its Content-Security-Policy is <code>default-src 'none'</code>. The links are references to follow, not resources it fetches.</p> <p><a href="https://github.com/88plug/k3d-gpu" rel="noreferrer">https://github.com/88plug/k3d-gpu</a></p> </footer></main></body></html> diff --git a/tests/fixtures/xeon-srf/facts.json b/tests/fixtures/xeon-srf/facts.json index 53c7abf0..8eb18ba4 100644 --- a/tests/fixtures/xeon-srf/facts.json +++ b/tests/fixtures/xeon-srf/facts.json @@ -264,8 +264,8 @@ "release": { "k3d_gpu_version": "<version>", "pins": { - "llama.cpp": "ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", - "ovms": "openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" + "llama.cpp": "ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e", + "ovms": "openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58" } }, "schema": "k3d-gpu.facts/v1", diff --git a/tests/fixtures/xeon-srf/report.html b/tests/fixtures/xeon-srf/report.html index 8d37a6f9..8bf558fa 100644 --- a/tests/fixtures/xeon-srf/report.html +++ b/tests/fixtures/xeon-srf/report.html @@ -67,9 +67,9 @@ <h2>Detected</h2> <h2>Node labels</h2> <div class="wrap"><table><tbody><tr><th>feature.node.kubernetes.io/cpu-cpuid.AVXVNNI</th><td>true</td></tr></tbody></table></div> <h2>Release pins</h2> -<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> +<div class="wrap"><table><tbody><tr><th>llama.cpp</th><td>ghcr.io/ggml-org/llama.cpp:server-vulkan-b11960@sha256:c9aba1a673382ad356a72fb63e6cd73e8c021797e904b6c099bfe130217df62e</td></tr><tr><th>ovms</th><td>openvino/model_server:2026.4.1-gpu@sha256:a928903fe429f1e3e27107cc45932d30d95febfd2d1159dc50869c3eac76fc58</td></tr></tbody></table></div> <footer> -<p>Rendered from facts/v1, sha256 of its canonical bytes <code>99404eb97e388bf6f49faa070d8723f9ebdfa30643b43b51a12cd2d30b6fbbaa</code>, and plan/v1.</p> +<p>Rendered from facts/v1, sha256 of its canonical bytes <code>b29b9d9777f36e1775f4870c3e88a8f0256d99d0a2ceb833fde27183a3ec1534</code>, and plan/v1.</p> <p>This page runs no script and loads nothing: its Content-Security-Policy is <code>default-src 'none'</code>. The links are references to follow, not resources it fetches.</p> <p><a href="https://github.com/88plug/k3d-gpu" rel="noreferrer">https://github.com/88plug/k3d-gpu</a></p> </footer></main></body></html> diff --git a/tests/gate_merge_test.sh b/tests/gate_merge_test.sh index b43faa27..762e8af3 100755 --- a/tests/gate_merge_test.sh +++ b/tests/gate_merge_test.sh @@ -7,8 +7,13 @@ # files changed on main and on the PR since the merge base). The stub gh answers # both `gh pr view --json statusCheckRollup` and `gh pr checks --json bucket` # from the same rollup (bucket mapping as in cli/cli pkg/cmd/pr/checks/aggregate.go). -# Cases 18-19 run the gate's sync() with real git against a bare origin (gh and +# `gh run list -e/-b` filter runs that carry event/headBranch (bare runs match +# any). Alarms: `gh issue list` answers the next line of alarms_seq (default +# none; issues_off makes it fail), and the PR's changed files are pr_paths +# (pr_paths_fail makes that fail). +# Cases 18-19 and 24-25 run the gate with real git against a bare origin (gh and # sleep still stubbed): the head the stub gh reports is the origin's feat ref. +# The ci_status and rules_stale cases source hack/gate_lib.sh directly. # # Usage: tests/gate_merge_test.sh @@ -65,12 +70,25 @@ case "$1 $2" in if [ -n "$q" ]; then jq -r "$q" <<<"$obj"; else jq "{$fields}" <<<"$obj" 2>/dev/null || echo "$obj"; fi exit 0;; "run list") - shift 2; sha= q= wf= - while [ $# -gt 0 ]; do case $1 in -c|--commit) sha=$2; shift 2;; -w|--workflow) wf=$2; shift 2;; -q) q=$2; shift 2;; *) shift;; esac; done - echo "run-list $sha ${wf:-all}" >> "$F/calls" + shift 2; sha= q= wf= ev= rb= + while [ $# -gt 0 ]; do case $1 in -c|--commit) sha=$2; shift 2;; -w|--workflow) wf=$2; shift 2;; -e|--event) ev=$2; shift 2;; -b|--branch) rb=$2; shift 2;; -q) q=$2; shift 2;; *) shift;; esac; done + echo "run-list $sha ${wf:-all} ${ev:-any}" >> "$F/calls" if [ -n "$wf" ] || [ ! -f "$F/allruns.json" ]; then out=$(cat "$F/runs.json"); else out=$(cat "$F/allruns.json"); fi + # -e and -b filter like the API on runs that carry event / headBranch; a bare run matches any. + out=$(jq --arg e "$ev" --arg b "$rb" \ + 'map(select(($e == "" or (.event // $e) == $e) and ($b == "" or (.headBranch // $b) == $b)))' <<<"$out") if [ -n "$q" ]; then jq -r "$q" <<<"$out"; else echo "$out"; fi exit 0;; + "issue list") + # open ci-alarm issues: the next line of alarms_seq (one JSON array per call), default none + [ -f "$F/issues_off" ] && { echo "the 'o/r' repository has disabled issues" >&2; exit 1; } + if [ -f "$F/alarms_seq" ]; then pop alarms_seq; else echo '[]'; fi + exit 0;; + "api --paginate") + case $* in */pulls/*/files*) + [ -f "$F/pr_paths_fail" ] && { echo "HTTP 502: Bad Gateway" >&2; exit 1; } + cat "$F/pr_paths"; exit 0;; esac + exit 0;; esac exit 0 STUB @@ -130,6 +148,7 @@ fixture() { echo 0 > "$FIX/ancestor"; echo scripts/k3d-gpu > "$FIX/base_files"; echo scripts/k3d-gpu > "$FIX/pr_files" printf '%s\n' ${6:-MERGEABLE} > "$FIX/mergeable"; echo "${7:-$GOOD_HELP}" > "$FIX/merge_help" echo OPEN > "$FIX/state"; : > "$FIX/calls"; : > "$FIX/git"; : > "$FIX/trace" + echo scripts/k3d-gpu > "$FIX/pr_paths" } run_gate() { OUT=$(PATH="$T/bin:$PATH" GATE_REPO="$FIX" GATE_WT_ROOT="$FIX/wt" GATE_POLLS=3 \ @@ -313,8 +332,12 @@ realgit_fixture() { printf '#!/bin/sh\nexit 0\n' > "$R/tests/claims_check.sh" printf '## Hypotheses\n\n| H34 | on the i9-13900T | p | m | SUPPORTED |\n' > "$R/EXPERIMENTS.md" echo base > "$R/docs/provenance.md" + mkdir -p "$R/scripts" "$R/packaging/aur" "$R/Formula" + printf '#!/usr/bin/env bash\nset -euo pipefail\n\nVERSION="0.1.0" # kept in lockstep\nCLUSTER=gpu\n' > "$R/scripts/k3d-gpu" + echo 'pkgver=0.1.0' > "$R/packaging/aur/PKGBUILD"; echo 'pkgver = 0.1.0' > "$R/packaging/aur/.SRCINFO" + echo 'VERSION=0.1.0' > "$R/packaging/install.sh"; echo 'version "0.1.0"' > "$R/Formula/k3d-gpu.rb" "$REALPY" "$R/console/model_opt.py" ledger-rules --ledger "$R/EXPERIMENTS.md" --out "$R/console/ledger-rules.json" - repo_git add console tests docs EXPERIMENTS.md && repo_git commit -q -m base \ + repo_git add console tests docs scripts packaging Formula EXPERIMENTS.md && repo_git commit -q -m base \ && repo_git remote add origin "$FIX/origin.git" && repo_git push -q origin main && repo_git checkout -q -b feat echo "$FIX/origin.git" > "$FIX/origin_git" } @@ -373,5 +396,213 @@ if [ "$RC" -eq 1 ] && ! grep -q 'synced' <<<"$OUT" && [ "$(syncs)" -eq 1 ] && [ ok "sync_merge_commit_fails: exit 1 at once, no push" else fail "sync_merge_commit_fails: rc=$RC syncs=$(syncs) merges=$(merges) origin=$before->${after:-gone} out=$(tr '\n' ';' <<<"$OUT")"; fi +# ─── CI on bot PRs: a ci.yml run dispatched on the PR branch is CI evidence ──── +# A PR that GITHUB_TOKEN opened gets pull_request runs held for approval +# (conclusion action_required); hack/bot-ci-dispatch runs ci.yml on the branch +# by workflow_dispatch instead. The stub PR's branch is feat. +# run_json EVENT BRANCH STATUS CONCLUSION CREATED: one ci.yml run for the head SHA. +run_json() { printf '{"workflowName":"CI","event":"%s","headBranch":"%s","status":"%s","conclusion":"%s","createdAt":"%s"}' "$@"; } +HELD_PR=$(run_json pull_request feat completed action_required 2026-10-20T01:00:00Z) +DISP_OK=$(run_json workflow_dispatch feat completed success 2026-10-20T01:05:00Z) +HELD_CI_CHECK=$(concl ACTION_REQUIRED) +HELD_DEP_CHECK='{"__typename":"CheckRun","name":"automerge","workflowName":"Auto-merge Dependabot PRs","status":"COMPLETED","conclusion":"ACTION_REQUIRED","startedAt":"2026-10-20T01:00:00Z"}' +HELD_DEP_RUN='{"workflowName":"Auto-merge Dependabot PRs","event":"pull_request","headBranch":"feat","status":"completed","conclusion":"action_required","createdAt":"2026-10-20T01:00:00Z"}' + +# 20. no pull_request run at all, a green ci.yml run dispatched on the PR branch: merge. +fixture dispatch_green "[$SUCCESS]" "[$DISP_OK]" abc123; run_gate +if [ "$RC" -eq 0 ] && [ "$(merges)" -eq 1 ] && grep -q -- '--match-head-commit abc123' "$FIX/merges"; then + ok "dispatch_green: a dispatched run on the branch counts as CI" +else fail "dispatch_green: rc=$RC merges=$(merges) out=$(tr '\n' ';' <<<"$OUT")"; fi + +# 21. held pull_request runs (CI and the dependabot workflow, in the rollup and the +# run list) plus a green dispatched run: the held entries do not block; merge. +fixture held_plus_dispatch "[$SUCCESS,$HELD_CI_CHECK,$HELD_DEP_CHECK]" "[$HELD_PR,$DISP_OK]" abc123 +echo "[$HELD_PR,$HELD_DEP_RUN,$DISP_OK]" > "$FIX/allruns.json"; run_gate +if [ "$RC" -eq 0 ] && [ "$(merges)" -eq 1 ]; then ok "held_plus_dispatch: held runs ignored once a dispatched run is green" +else fail "held_plus_dispatch: rc=$RC merges=$(merges) out=$(tr '\n' ';' <<<"$OUT")"; fi + +# 22. held alone still fails at once, as before: a held CheckRun in the rollup, or +# (no check runs for a held run) only the held ci.yml run. +fixture held_alone_rollup "[$SUCCESS,$HELD_CI_CHECK]" "[$HELD_PR]" abc123; run_gate +if [ "$RC" -eq 1 ] && [ "$(merges)" -eq 0 ] && grep -q FAIL <<<"$OUT"; then ok "held_alone_rollup: exit 1" +else fail "held_alone_rollup: rc=$RC merges=$(merges)"; fi +fixture held_alone_run "[$SUCCESS]" "[$HELD_PR]" abc123; run_gate +if [ "$RC" -eq 1 ] && [ "$(merges)" -eq 0 ] && grep -q FAIL <<<"$OUT"; then ok "held_alone_run: exit 1" +else fail "held_alone_run: rc=$RC merges=$(merges)"; fi + +# 23. a green run dispatched on another branch is not this PR's CI: with nothing +# else no merge (wait), and it does not excuse a held run (exit 1). +fixture dispatch_other_branch "[$SUCCESS]" "[$(run_json workflow_dispatch other completed success 2026-10-20T01:05:00Z)]" abc123; run_gate +if [ "$(merges)" -eq 0 ] && grep -q 'GAVE UP' <<<"$OUT"; then ok "dispatch_other_branch: not accepted, waits" +else fail "dispatch_other_branch: rc=$RC merges=$(merges)"; fi +fixture dispatch_other_branch_held "[$SUCCESS,$HELD_CI_CHECK]" "[$HELD_PR,$(run_json workflow_dispatch other completed success 2026-10-20T01:05:00Z)]" abc123; run_gate +if [ "$RC" -eq 1 ] && [ "$(merges)" -eq 0 ]; then ok "dispatch_other_branch_held: held run still fails" +else fail "dispatch_other_branch_held: rc=$RC merges=$(merges)"; fi + +# 23b. the latest dispatched run failed after an earlier green one: exit 1. +fixture dispatch_latest_failed "[$SUCCESS]" "[$DISP_OK,$(run_json workflow_dispatch feat completed failure 2026-10-20T02:00:00Z)]" abc123; run_gate +if [ "$RC" -eq 1 ] && [ "$(merges)" -eq 0 ]; then ok "dispatch_latest_failed: exit 1" +else fail "dispatch_latest_failed: rc=$RC merges=$(merges)"; fi + +# ─── ci_status and rules_stale, called from hack/gate_lib.sh directly ───────── +lib_ci() { # lib_ci NAME RUNS WANT: ci_status abc123 feat over the ci.yml runs RUNS + fixture "lib_$1" '[]' "$2" abc123 + local got; got=$(PATH="$T/bin:$PATH" bash -c '. "$1"; ci_status abc123 feat' _ "$ROOT/hack/gate_lib.sh" 2>&1) + if [ "$got" = "$3" ]; then ok "ci_status $1: $got"; else fail "ci_status $1: got '$got', want $3"; fi +} +lib_ci none '[]' none +lib_ci pr_green "[$(run_json pull_request feat completed success 2026-10-20T01:00:00Z)]" green +lib_ci dispatch_green "[$DISP_OK]" green +lib_ci held_only "[$HELD_PR]" failed +lib_ci held_plus_dispatch "[$HELD_PR,$DISP_OK]" green +lib_ci held_plus_running "[$HELD_PR,$(run_json workflow_dispatch feat in_progress '' 2026-10-20T01:05:00Z)]" pending +lib_ci dispatch_queued "[$(run_json workflow_dispatch feat queued '' 2026-10-20T01:05:00Z)]" pending +lib_ci other_branch "[$(run_json workflow_dispatch other completed success 2026-10-20T01:05:00Z)]" none +lib_ci latest_failed "[$DISP_OK,$(run_json workflow_dispatch feat completed failure 2026-10-20T02:00:00Z)]" failed +lib_ci failed_then_green "[$(run_json workflow_dispatch feat completed failure 2026-10-20T01:00:00Z),$DISP_OK]" green +lib_ci failed_then_running "[$(run_json workflow_dispatch feat completed failure 2026-10-20T01:00:00Z),$(run_json workflow_dispatch feat in_progress '' 2026-10-20T01:05:00Z)]" pending + +# rules_stale keeps its behaviour: stale tree -> 0, matching tree -> 1, no tree -> 1. +lib_rules() { # lib_rules NAME WANT_RC [current] + fixture "librules_$1" '[]' '[]' abc123 + mkdir -p "$FIX/show/console"; cp "$ROOT/console/model_opt.py" "$ROOT/console/convert-pins.json" "$FIX/show/console/" + printf '## Hypotheses\n\n| H34 | on the i9-13900T | p | m | SUPPORTED |\n' > "$FIX/show/EXPERIMENTS.md" + if [ "${3:-}" = current ]; then + python3 "$ROOT/console/model_opt.py" ledger-rules --ledger "$FIX/show/EXPERIMENTS.md" --out "$FIX/show/console/ledger-rules.json" + else echo '{"schema": "k3d-gpu.ledger-rules/v1", "rows": []}' > "$FIX/show/console/ledger-rules.json"; fi + [ "$1" = notree ] || echo t1 > "$FIX/tree" + PATH="$T/bin:$PATH" bash -c '. "$1"; rules_stale abc123' _ "$ROOT/hack/gate_lib.sh" >/dev/null 2>&1; local rc=$? + if [ "$rc" -eq "$2" ]; then ok "rules_stale $1: rc $rc"; else fail "rules_stale $1: rc $rc, want $2"; fi +} +lib_rules stale 0; lib_rules current 1 current; lib_rules notree 1 + +# ─── base_ok: release-only bot commits on main do not count as a moved base ──── +# release_to_main AUTHOR SUBJECT [code]: one commit on main that moves the version +# in scripts/k3d-gpu's VERSION line and the AUR, install.sh and Formula files; +# with `code` it also changes another line of scripts/k3d-gpu. +release_to_main() { + local R=$FIX/repo + repo_git checkout -q main + sed -i 's/^VERSION="0\.1\.0"/VERSION="0.1.1"/' "$R/scripts/k3d-gpu" + [ "${3:-}" = code ] && sed -i 's/^CLUSTER=gpu$/CLUSTER=cpu/' "$R/scripts/k3d-gpu" + echo 'pkgver=0.1.1' > "$R/packaging/aur/PKGBUILD"; echo 'pkgver = 0.1.1' > "$R/packaging/aur/.SRCINFO" + repo_git add scripts packaging/aur && GIT_AUTHOR_NAME=$1 "$REALGIT" -C "$R" commit -q -m "$2" && repo_git push -q origin main +} +# install_to_main AUTHOR: the install.sh and Homebrew release commits. +install_to_main() { + local R=$FIX/repo + repo_git checkout -q main + echo 'VERSION=0.1.1' > "$R/packaging/install.sh"; repo_git add packaging/install.sh + GIT_AUTHOR_NAME=$1 "$REALGIT" -C "$R" commit -q -m "release: install.sh k3d-gpu 0.1.1 [skip release]" + echo 'version "0.1.1"' > "$R/Formula/k3d-gpu.rb"; repo_git add Formula/k3d-gpu.rb + GIT_AUTHOR_NAME=$1 "$REALGIT" -C "$R" commit -q -m "release: homebrew k3d-gpu 0.1.1 [skip release]" + repo_git push -q origin main +} + +# 24. the PR changes code, and main moved only by release-only bot commits: merge, no sync. +realgit_fixture release_only_base "[$SUCCESS]" "$CI_OK" +commit_to feat scripts.txt $'pr\n' +release_to_main 88plug-bot "release: aur-v0.1.1 [skip release]"; install_to_main 'github-actions[bot]' +run_gate_real +if [ "$RC" -eq 0 ] && [ "$(merges)" -eq 1 ] && [ "$(syncs)" -eq 0 ] && ! grep -q 'main moved under code' <<<"$OUT"; then + ok "release_only_base: merged, no sync" +else fail "release_only_base: rc=$RC merges=$(merges) syncs=$(syncs) out=$(tr '\n' ';' <<<"$OUT")"; fi + +# 25. a release commit that also changes another launcher line is a code move: sync first. +realgit_fixture release_with_code "[$SUCCESS]" "$CI_OK" +commit_to feat scripts.txt $'pr\n' +release_to_main 88plug-bot "release: aur-v0.1.1 [skip release]" code +run_gate_real +if [ "$RC" -eq 0 ] && [ "$(merges)" -eq 1 ] && [ "$(syncs)" -eq 1 ] && grep -q 'main moved under code' <<<"$OUT"; then + ok "release_with_code: synced before the merge" +else fail "release_with_code: rc=$RC merges=$(merges) syncs=$(syncs) out=$(tr '\n' ';' <<<"$OUT")"; fi + +# 25b. the same release files under a release subject, but by a human: a code move. +realgit_fixture release_by_human "[$SUCCESS]" "$CI_OK" +commit_to feat scripts.txt $'pr\n' +release_to_main gate-test "release: aur-v0.1.1 [skip release]" +run_gate_real +if [ "$RC" -eq 0 ] && [ "$(syncs)" -eq 1 ] && grep -q 'main moved under code' <<<"$OUT"; then + ok "release_by_human: synced before the merge" +else fail "release_by_human: rc=$RC merges=$(merges) syncs=$(syncs) out=$(tr '\n' ';' <<<"$OUT")"; fi + +# 25c. a bot commit on release files without [skip release] in its subject: a code move. +realgit_fixture release_no_skip "[$SUCCESS]" "$CI_OK" +commit_to feat scripts.txt $'pr\n' +release_to_main 88plug-bot "release: aur-v0.1.1" +run_gate_real +if [ "$RC" -eq 0 ] && [ "$(syncs)" -eq 1 ]; then ok "release_no_skip: synced before the merge" +else fail "release_no_skip: rc=$RC merges=$(merges) syncs=$(syncs) out=$(tr '\n' ';' <<<"$OUT")"; fi + +# ─── alarms: open ci-alarm issues are printed, and refuse shipped changes ────── +UNACKED='{"number":7,"title":"[ci-alarm] nightly: Auto-update red","url":"https://github.com/o/r/issues/7","labels":[{"name":"ci-alarm"}]}' +ACKED='{"number":8,"title":"[ci-alarm] release: 0.1.79 missing on GHCR","url":"https://github.com/o/r/issues/8","labels":[{"name":"ci-alarm"},{"name":"alarm-ack"}]}' + +# 26. an un-acked alarm and a PR that changes the launcher: exit 4, URL and ack command shown. +fixture alarm_unacked "[$SUCCESS]" "$CI_OK" abc123; echo "[$UNACKED]" > "$FIX/alarms_seq"; run_gate +if [ "$RC" -eq 4 ] && [ "$(merges)" -eq 0 ] && grep -qF 'https://github.com/o/r/issues/7' <<<"$OUT" \ + && grep -qF 'gh issue edit 7 --add-label alarm-ack' <<<"$OUT"; then + ok "alarm_unacked: refused with exit 4, URL and ack command printed" +else fail "alarm_unacked: rc=$RC merges=$(merges) out=$(tr '\n' ';' <<<"$OUT")"; fi + +# 26b. a workflow-only PR is refused too; so is a PR whose files cannot be listed. +fixture alarm_workflow "[$SUCCESS]" "$CI_OK" abc123; echo "[$UNACKED]" > "$FIX/alarms_seq" +echo .github/workflows/ci.yml > "$FIX/pr_paths"; run_gate +if [ "$RC" -eq 4 ] && [ "$(merges)" -eq 0 ]; then ok "alarm_workflow: refused" +else fail "alarm_workflow: rc=$RC merges=$(merges)"; fi +fixture alarm_files_unknown "[$SUCCESS]" "$CI_OK" abc123; echo "[$UNACKED]" > "$FIX/alarms_seq"; touch "$FIX/pr_paths_fail"; run_gate +if [ "$RC" -eq 4 ] && [ "$(merges)" -eq 0 ]; then ok "alarm_files_unknown: refused" +else fail "alarm_files_unknown: rc=$RC merges=$(merges)"; fi + +# 26c. GATE_RELEASE_PATHS adds shipped paths (hack/release-paths format: one +# pattern per line, # comments, dir/** for a tree). +fixture alarm_release_paths "[$SUCCESS]" "$CI_OK" abc123; echo "[$UNACKED]" > "$FIX/alarms_seq" +printf '# shipped by nfpm\noracle/**\n' > "$FIX/release-paths"; echo oracle/bundle.py > "$FIX/pr_paths" +OUT=$(PATH="$T/bin:$PATH" GATE_REPO="$FIX" GATE_WT_ROOT="$FIX/wt" GATE_POLLS=3 GATE_RELEASE_PATHS="$FIX/release-paths" \ + timeout 60 bash "$GATE" 1 subj body 2>&1); RC=$? +if [ "$RC" -eq 4 ] && [ "$(merges)" -eq 0 ]; then ok "alarm_release_paths: a listed path is refused" +else fail "alarm_release_paths: rc=$RC merges=$(merges) out=$(tr '\n' ';' <<<"$OUT")"; fi + +# 26d. no alarm at the start, an un-acked one by the merge: refused before merging. +fixture alarm_opens_later "[$SUCCESS]" "$CI_OK" abc123; printf '[]\n[%s]\n' "$UNACKED" > "$FIX/alarms_seq"; run_gate +if [ "$RC" -eq 4 ] && [ "$(merges)" -eq 0 ]; then ok "alarm_opens_later: re-checked and refused before the merge" +else fail "alarm_opens_later: rc=$RC merges=$(merges) out=$(tr '\n' ';' <<<"$OUT")"; fi + +# 27. every open alarm acked: listed at the start, and the PR merges. +fixture alarm_acked "[$SUCCESS]" "$CI_OK" abc123; echo "[$ACKED]" > "$FIX/alarms_seq"; run_gate +if [ "$RC" -eq 0 ] && [ "$(merges)" -eq 1 ] && grep -F '#8' <<<"$OUT" | grep -qF 'https://github.com/o/r/issues/8'; then + ok "alarm_acked: listed, merged" +else fail "alarm_acked: rc=$RC merges=$(merges) out=$(tr '\n' ';' <<<"$OUT")"; fi + +# 28. ledger, docs, bench and test changes are never refused; nor is a path outside +# the shipped set. +fixture alarm_ledger_only "[$SUCCESS]" "$CI_OK" abc123; echo "[$UNACKED,$ACKED]" > "$FIX/alarms_seq" +printf 'EXPERIMENTS.md\ndocs/provenance.md\nbench/h54/run.sh\ntests/bench_test.sh\n' > "$FIX/pr_paths"; run_gate +if [ "$RC" -eq 0 ] && [ "$(merges)" -eq 1 ] && grep -qF 'issues/7' <<<"$OUT"; then ok "alarm_ledger_only: listed, merged" +else fail "alarm_ledger_only: rc=$RC merges=$(merges) out=$(tr '\n' ';' <<<"$OUT")"; fi +fixture alarm_unshipped "[$SUCCESS]" "$CI_OK" abc123; echo "[$UNACKED]" > "$FIX/alarms_seq" +echo README.md > "$FIX/pr_paths"; run_gate +if [ "$RC" -eq 0 ] && [ "$(merges)" -eq 1 ]; then ok "alarm_unshipped: merged" +else fail "alarm_unshipped: rc=$RC merges=$(merges)"; fi + +# 30. the gate copied alone (a scratchpad shim) finds gate_lib.sh in the repository's +# hack/; with neither copy it stops before merging. +fixture solo_gate "[$SUCCESS]" "$CI_OK" abc123; mkdir -p "$FIX/solo" "$FIX/hack" +cp "$GATE" "$FIX/solo/gate_merge.sh"; cp "$ROOT/hack/gate_lib.sh" "$FIX/hack/" +OUT=$(PATH="$T/bin:$PATH" GATE_REPO="$FIX" GATE_WT_ROOT="$FIX/wt" GATE_POLLS=3 timeout 60 bash "$FIX/solo/gate_merge.sh" 1 subj body 2>&1); RC=$? +if [ "$RC" -eq 0 ] && [ "$(merges)" -eq 1 ]; then ok "solo_gate: gate_lib.sh read from the repository's hack/" +else fail "solo_gate: rc=$RC merges=$(merges) out=$(tr '\n' ';' <<<"$OUT")"; fi +rm "$FIX/hack/gate_lib.sh"; echo OPEN > "$FIX/state"; rm -f "$FIX/merges" +OUT=$(PATH="$T/bin:$PATH" GATE_REPO="$FIX" GATE_WT_ROOT="$FIX/wt" GATE_POLLS=3 timeout 60 bash "$FIX/solo/gate_merge.sh" 1 subj body 2>&1); RC=$? +if [ "$RC" -eq 1 ] && [ "$(merges)" -eq 0 ] && grep -q 'gate_lib.sh not found' <<<"$OUT"; then ok "solo_gate: no gate_lib.sh, exit 1" +else fail "solo_gate missing lib: rc=$RC merges=$(merges) out=$(tr '\n' ';' <<<"$OUT")"; fi + +# 29. issues cannot be listed (the repository has them disabled): the gate says so, +# with gh's reason, and goes on. +fixture alarm_issues_off "[$SUCCESS]" "$CI_OK" abc123; touch "$FIX/issues_off"; run_gate +if [ "$RC" -eq 0 ] && [ "$(merges)" -eq 1 ] && grep -q 'WARNING: could not list the open ci-alarm issues.*disabled issues' <<<"$OUT"; then + ok "alarm_issues_off: warned with the reason, merged" +else fail "alarm_issues_off: rc=$RC merges=$(merges) out=$(tr '\n' ';' <<<"$OUT")"; fi + echo "${PASS} passed, ${FAILS} failed" [ "${FAILS}" -eq 0 ] diff --git a/tests/image_pins_test.sh b/tests/image_pins_test.sh index a30ac4a4..a3d3e6b5 100755 --- a/tests/image_pins_test.sh +++ b/tests/image_pins_test.sh @@ -39,10 +39,14 @@ else fail "images-lock pins on the repo"; sed 's/^/ | /' <<< "${out}"; fi # scratch: a root holding the images-lock tool and an allow file with one entry. scratch() { - rm -rf "${WORK}/r"; mkdir -p "${WORK}/r/scripts" "${WORK}/r/share" + rm -rf "${WORK}/r"; mkdir -p "${WORK}/r/scripts" "${WORK}/r/share" "${WORK}/r/hack" "${WORK}/r/tests" cp "${REPO}/scripts/images-lock" "${WORK}/r/scripts/" + cp "${REPO}/hack/pin-classes" "${WORK}/r/hack/" + cp "${REPO}/tests/ledger_drift_check.py" "${WORK}/r/tests/" cp -r "${REPO}/console" "${WORK}/r/" printf 'share/local.yml app/local:dev built on the host, never pulled\n' > "${WORK}/r/images.unpinned" + mkdir -p "${WORK}/r/pins" + printf 'console/matrix.v1.json historical the measured cells (digest-only records)\n' > "${WORK}/r/pins/classes" printf -- '- image: app/local:dev\n' > "${WORK}/r/share/local.yml" } @@ -90,8 +94,14 @@ pins_case "name:tag@sha256 references" ok \ "printf -- '- image: redis:7.4@${D1}\nFROM debian:trixie-slim@${D2}\n' > share/a.yml" pins_case "a FROM whose ARG default is pinned" ok \ "printf 'ARG UBUNTU_IMAGE=\"ubuntu:26.04@${D1}\"\nFROM \${UBUNTU_IMAGE}\n' > Dockerfile" -pins_case "digest-only references" ok \ +pins_case "a digest-only reference in an ours file" "scripts/run:1: openvino/model_server@${D1} has no tag" \ "printf 'AGENT_OVMS_IMAGE=\"openvino/model_server@${D1}\"\n' > scripts/run" +pins_case "a digest-only reference in an eat-stock file" "integrations/eat/run:1: intel/x@${D1} has no tag" \ + "mkdir -p integrations/eat; printf 'integrations/eat/** eat-stock EAT\n' >> pins/classes; printf 'X_IMAGE=intel/x@${D1}\n' > integrations/eat/run" +pins_case "a digest-only reference images.unpinned allows" ok \ + "printf 'AGENT_OVMS_IMAGE=\"openvino/model_server@${D1}\"\n' > scripts/run; printf 'scripts/run openvino/model_server@* a test input\n' >> images.unpinned" +pins_case "digest-only references in ledger and historical files (records)" ok \ + "mkdir -p bench/h bench/l; printf 'bench/h/** historical done\nbench/l/** ledger H1 amend=X a row\n' >> pins/classes; printf 'IMG=openvino/model_server@${D1}\n' | tee bench/h/run.sh > bench/l/run.sh" pins_case "variables" ok \ "printf -- '- image: \${AGENT_IMAGE}\n- image: \"@VLLM_IMAGE@\"\n- image: {image}\n' > share/a.yml" pins_case "a CRD schema image: key" ok \ @@ -235,6 +245,179 @@ if out=$("${WORK}/r/scripts/images-lock" check --exclude 'x' --root "${WORK}/r" elif grep -qF -- '--exclude' <<< "${out}"; then ok "--exclude is a repin option only" else fail "check --exclude failed for another reason"; sed 's/^/ | /' <<< "${out}"; fi +# ---- repin and drift leave ledger and historical paths (pins/classes) alone ---- +# pyrun <python>: run python with `m` (scratch images-lock) loaded, the registry stubbed +# by STUB (a dict name:tag -> digest, else D2), and argv[1:] = root, D1, D2. +pyrun() { + python3 - "${WORK}/r" "${D1}" "${D2}" <<EOF +import importlib.machinery, importlib.util, sys +root, d1, d2 = sys.argv[1:] +loader = importlib.machinery.SourceFileLoader("images_lock", root + "/scripts/images-lock") +spec = importlib.util.spec_from_loader("images_lock", loader) +m = importlib.util.module_from_spec(spec) +loader.exec_module(m) +STUB = {} +m.registry_digest = lambda name, tag: STUB.get("%s:%s" % (name, tag), d2) +$1 +EOF +} +scratch +mkdir -p "${WORK}/r/pins" "${WORK}/r/bench/h" "${WORK}/r/bench/l" +printf 'bench/h/** historical a finished run\nbench/l/** ledger H1 amend=X a row\nhistorical-ref scripts/mixed.sh keep-me\n' >> "${WORK}/r/pins/classes" +for f in bench/h/run.sh bench/l/run.sh scripts/mixed.sh share/a.yml; do + printf -- '- image: alpine:3.22@%s\n' "${D1}" > "${WORK}/r/${f}" +done +echo "keep-me" >> "${WORK}/r/scripts/mixed.sh" +cp -r "${WORK}/r" "${WORK}/before" +drift_out=$(pyrun 'sys.exit(m.main(["images-lock", "drift", "--root", root]))' 2>&1); rc=$? +if [ "${rc}" -eq 0 ] && grep -qxF "share/a.yml:1: alpine:3.22 ${D1} -> ${D2}" <<< "${drift_out}" \ + && ! grep -qE 'bench/|mixed' <<< "${drift_out}" && diff -r -x __pycache__ "${WORK}/before" "${WORK}/r" > /dev/null; then + ok "drift lists a moved pin, writes nothing and skips ledger, historical and historical-ref files" +else fail "drift (exit ${rc})"; sed 's/^/ | /' <<< "${drift_out}"; fi +repin_out=$(pyrun 'sys.exit(m.main(["images-lock", "repin", "--root", root]))' 2>&1); rc=$? +if [ "${rc}" -eq 0 ] && [ "$(cat "${WORK}/r/share/a.yml")" = "- image: alpine:3.22@${D2}" ] \ + && cmp -s "${WORK}/before/bench/h/run.sh" "${WORK}/r/bench/h/run.sh" \ + && cmp -s "${WORK}/before/bench/l/run.sh" "${WORK}/r/bench/l/run.sh" \ + && cmp -s "${WORK}/before/scripts/mixed.sh" "${WORK}/r/scripts/mixed.sh"; then + ok "repin leaves ledger, historical and historical-ref files untouched and moves the rest" +else fail "repin touched a ledger or historical file (exit ${rc})"; sed 's/^/ | /' <<< "${repin_out}"; fi +out=$(pyrun 'sys.exit(m.main(["images-lock", "repin", "--root", root, "bench/l/run.sh"]))' 2>&1) +if cmp -s "${WORK}/before/bench/l/run.sh" "${WORK}/r/bench/l/run.sh"; then ok "repin FILE skips a ledger file named on the command line" +else fail "repin FILE rewrote a ledger file"; sed 's/^/ | /' <<< "${out}"; fi + +# ---- update --only FILE...: one drifted digest elsewhere does not block a bump ---- +scratch +printf 'ARG K3S_TAG=v1.37.1-k3s1\nFROM debian:trixie-slim@%s\n' "${D1}" > "${WORK}/r/Dockerfile" +: > "${WORK}/r/scripts/k3d-gpu" +printf -- '- image: quay.io/a/a:1@%s\n' "${D1}" > "${WORK}/r/share/a.yml" +printf -- '- image: quay.io/b/b:1@%s\n' "${D1}" > "${WORK}/r/share/b.yml" +rm "${WORK}/r/share/local.yml"; : > "${WORK}/r/images.unpinned" +# update_case NAME WANT_RC GREP [ARGS...]: `update ARGS` in the scratch root. Every +# registry stub serves D1 except the name:tag refs DRIFT lists (they serve D2); +# k3s_images is stubbed and prints when it is called. GREP '!re' wants no match. +update_case() { + local name=$1 want=$2 pat=$3 out rc hit + shift 3 + out=$(update_args "$@" 2>&1); rc=$? + case "${pat}" in + "") hit=0 ;; + !*) ! grep -qE -- "${pat#!}" <<< "${out}"; hit=$? ;; + *) grep -qE -- "${pat}" <<< "${out}"; hit=$? ;; + esac + if [ "${rc}" = "${want}" ] && [ "${hit}" = 0 ]; then ok "${name}" + else fail "${name} (exit ${rc}, want ${want})"; sed 's/^/ | /' <<< "${out}"; fi +} +update_args() { + DRIFT="${DRIFT:-}" python3 - "${WORK}/r" "${D1}" "${D2}" "$@" <<'EOF' +import importlib.machinery, importlib.util, os, sys +root, d1, d2 = sys.argv[1:4] +loader = importlib.machinery.SourceFileLoader("images_lock", root + "/scripts/images-lock") +spec = importlib.util.spec_from_loader("images_lock", loader) +m = importlib.util.module_from_spec(spec) +loader.exec_module(m) +drift = os.environ.get("DRIFT", "").split() +m.registry_digest = lambda name, tag: d2 if "%s:%s" % (name, tag) in drift else d1 +def k3s(tag): + print("k3s-images.txt fetched for " + tag) + return ["docker.io/rancher/mirrored-pause:3.6"] +m.k3s_images = k3s +sys.exit(m.main(["images-lock", "update", "--root", root] + sys.argv[4:])) +EOF +} +update_case "update writes images.lock (all registries agree)" 0 "k3s-images.txt fetched for v1.37.1-k3s1" +cp "${WORK}/r/images.lock" "${WORK}/lock.before" +DRIFT="quay.io/b/b:1" update_case "update without --only fails on a drifted digest anywhere" 2 \ + "quay.io/b/b:1: the repo pins ${D1} but the registry serves ${D2}" +DRIFT="quay.io/b/b:1" update_case "update --only share/a.yml ignores the drift in share/b.yml" 0 "" --only share/a.yml +if cmp -s "${WORK}/lock.before" "${WORK}/r/images.lock"; then ok "update --only keeps the entries of files it does not name" +else fail "update --only rewrote other entries"; diff "${WORK}/lock.before" "${WORK}/r/images.lock" | sed 's/^/ | /'; fi +DRIFT="quay.io/b/b:1" DRIFT="quay.io/b/b:1" update_case "update --only does not fetch k3s-images.txt unless the Dockerfile is named" 0 \ + "!k3s-images.txt fetched" --only share/a.yml +DRIFT="quay.io/b/b:1" update_case "update --only share/b.yml still validates the file it names" 2 \ + "quay.io/b/b:1: the repo pins ${D1} but the registry serves ${D2}" --only share/b.yml +DRIFT="quay.io/b/b:1" update_case "update --only Dockerfile refreshes k3s-images.txt" 0 "k3s-images.txt fetched" --only Dockerfile +printf -- '- image: quay.io/c/c:1@%s\n' "${D1}" > "${WORK}/r/share/c.yml" +update_case "update --only resolves a new reference of a named file" 0 "" --only share/c.yml +if grep -qF '"name": "quay.io/c/c"' "${WORK}/r/images.lock"; then ok "update --only adds the named file's new reference" +else fail "update --only did not add quay.io/c/c"; fi +printf -- '- image: quay.io/d/d:1@%s\n' "${D1}" > "${WORK}/r/share/d.yml" +update_case "update --only refuses a reference images.lock lacks from a file it does not name" 2 \ + "quay.io/d/d:1 .*share/d.yml.* not in images.lock" --only share/c.yml +rm "${WORK}/r/share/d.yml" +printf -- '- image: quay.io/a/a:1@%s\n' "${D2}" > "${WORK}/r/share/a.yml" +update_case "update --only refuses a digest images.lock does not hold, from a file it does not name" 2 \ + "quay.io/a/a:1 .*share/a.yml.* pins ${D2}, images.lock has ${D1}" --only share/c.yml +printf -- '- image: quay.io/a/a:1@%s\n' "${D1}" > "${WORK}/r/share/a.yml" +printf -- '- image: quay.io/d/d:1@%s\n' "${D1}" > "${WORK}/r/share/d.yml" +sed -i 's/v1.37.1-k3s1/v1.37.2-k3s1/' "${WORK}/r/Dockerfile" +update_case "update --only refuses a moved K3S_TAG unless the Dockerfile is named" 2 "K3S_TAG" --only share/d.yml + +# ---- http(): 429 and 5xx retried, Retry-After honoured (capped), other statuses not ---- +if out=$(python3 - "${REPO}" 2>&1 <<'EOF' +import http.server, importlib.machinery, importlib.util, sys, threading, urllib.error, urllib.request +loader = importlib.machinery.SourceFileLoader("images_lock", sys.argv[1] + "/scripts/images-lock") +spec = importlib.util.spec_from_loader("images_lock", loader) +m = importlib.util.module_from_spec(spec) +loader.exec_module(m) +PLAN, SLEPT = [], [] +class H(http.server.BaseHTTPRequestHandler): + def do_HEAD(self): + code, retry = PLAN.pop(0) if PLAN else (200, None) + self.send_response(code) + if retry is not None: + self.send_header("Retry-After", retry) + if code == 200: + self.send_header("Docker-Content-Digest", "sha256:" + "3" * 64) + self.end_headers() + def log_message(self, *a): + pass +srv = http.server.HTTPServer(("127.0.0.1", 0), H) +threading.Thread(target=srv.serve_forever, daemon=True).start() +url = "http://127.0.0.1:%d/v2/x/y/manifests/1" % srv.server_port +m.time.sleep = SLEPT.append +def get(): + with m.http(urllib.request.Request(url, method="HEAD")) as r: + return r.headers["Docker-Content-Digest"] +def case(plan, slept, ok=True): + PLAN[:] = plan + SLEPT.clear() + try: + got = get() + except urllib.error.HTTPError as e: + got = e.code + assert (got == "sha256:" + "3" * 64) == ok, (plan, got) + assert SLEPT == slept, (plan, SLEPT, slept) + return got +case([(429, "7")], [7]) +case([(503, None), (502, None)], [2, 4]) +case([(429, "500")], [120]) +case([(429, "Wed, 21 Oct 2015 07:28:00 GMT")], [0]) +assert case([(429, "1")] * 5, [1, 1, 1, 1], ok=False) == 429 +assert case([(404, None)], [], ok=False) == 404 +print("ok") +EOF +); then ok "http() retries 429/5xx up to 5 attempts, honours Retry-After (seconds or date, capped at 120 s), not 404" +else fail "http() retry"; sed 's/^/ | /' <<< "${out}"; fi + +# ---- the repo: pins and repin read the CI tool pins (hack/ci-images.env, hack/ci-tools.env) ---- +if out=$(python3 - "${REPO}" 2>&1 <<'EOF' +import importlib.machinery, importlib.util, sys +root = sys.argv[1] +loader = importlib.machinery.SourceFileLoader("images_lock", root + "/scripts/images-lock") +spec = importlib.util.spec_from_loader("images_lock", loader) +m = importlib.util.module_from_spec(spec) +loader.exec_module(m) +files = m.scanned_files(root) +for f, want in (("hack/ci-images.env", "rhysd/actionlint:"), ("hack/ci-tools.env", "ghcr.io/yannh/kubeconform:")): + refs = [r for _, r, _ in m.image_refs(m.read_text(root, f), f)] if f in files else [] + assert any(r.startswith(want) and m.is_pinned(r) for r in refs), (f, refs) + assert not m.frozen(root, f), f +# pins/classes quotes finished runs' refs (historical-ref): repin must never move them. +assert "pins/classes" not in files and "images.unpinned" not in files +EOF +); then ok "pins and repin read hack/ci-images.env and hack/ci-tools.env (tagged, pinned, never frozen), not pins/classes" +else fail "images-lock does not scan the CI tool pins"; sed 's/^/ | /' <<< "${out}"; fi + # ---- the repo: no workflow file holds a pin the nightly would have to move ---- # repin-images runs `repin --exclude '.github/workflows/*'`, so a name:tag@sha256 # written in a workflow would never move again. Workflows read their image pins diff --git a/tests/image_test.sh b/tests/image_test.sh index ee487f3d..84879229 100755 --- a/tests/image_test.sh +++ b/tests/image_test.sh @@ -167,10 +167,33 @@ contract() { local name=$1 file=$2 re=$3 if matches "${re}" "$(cat "${WF}/${file}")"; then pass "${name}"; else fail "${name} (/${re}/ in ${file})"; fi } -contract "release build publishes <repo>:<version> on Docker Hub" build-and-push.yml \ - '^ +cryptoandcoffee/k3d-gpu:\$\{\{ inputs\.release \}\}$' -contract "release build publishes <repo>:<version> on GHCR" build-and-push.yml \ - '^ +ghcr\.io/.*/k3d-gpu:\$\{\{ inputs\.release \}\}$' +# The build pushes the versioned tags hack/release-tags prints; every release gets +# <repo>:<version> on Docker Hub and GHCR, the newest one also the floating tags. +contract "release build pushes the versioned tags hack/release-tags prints" build-and-push.yml \ + 'tags=\$\(\.ci-main/hack/release-tags "\$RELEASE" "\$K3S"\)' +contract "release build pushes to Docker Hub and GHCR" build-and-push.yml \ + 'RELEASE_REPOS: cryptoandcoffee/k3d-gpu ghcr\.io/\$\{\{ github\.repository_owner \}\}/k3d-gpu$' +contract "release build retries the 26.04 build (attempt 3)" build-and-push.yml \ + '^ +- name: Build & push — Ubuntu 26\.04 \(attempt 3\)$' +contract "release build retries the 24.04 build (attempt 3)" build-and-push.yml \ + '^ +- name: Build & push — Ubuntu 24\.04 \(attempt 3\)$' +contract "release build moves the floating tags only through hack/release-tags" build-and-push.yml \ + 'docker buildx imagetools create --tag "\$ref" "\$src"' +contract "release build pushes no literal :latest tag" build-and-push.yml \ + '!^ +(cryptoandcoffee|ghcr\.io/.*)/k3d-gpu:latest$' +RT_ORIGIN="${WORK}/rt-origin.git" +( export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 + git init -q --bare "${RT_ORIGIN}" && cd "${WORK}" && git init -q rt && cd rt \ + && git -c user.name=t -c user.email=t@t commit -q --allow-empty -m r && git tag "aur-v${VERSION}" \ + && git push -q "${RT_ORIGIN}" "aur-v${VERSION}" ) +rt_out=$(cd "${WORK}/rt" && GIT_CONFIG_GLOBAL=/dev/null REMOTE="${RT_ORIGIN}" bash "${ROOT}/hack/release-tags" "${VERSION}" v1.37.1-k3s1 2>&1) +if matches "^26\.04 versioned $(ere "${PINNED}")$" "${rt_out}" \ + && matches "^26\.04 versioned ghcr\.io/88plug/k3d-gpu:$(ere "${VERSION}")$" "${rt_out}" \ + && matches "^26\.04 floating $(ere "${REPO}:latest ${PINNED}")$" "${rt_out}"; then + pass "hack/release-tags gives the launcher's <repo>:<version> and moves latest for the newest release" +else + fail "hack/release-tags does not print ${PINNED} (versioned) and ${REPO}:latest (floating): ${rt_out}" +fi contract "release build checks out the release tag" build-and-push.yml \ "ref: .*aur-v" contract "release build dispatches the AUR deploy after pushing" build-and-push.yml \ diff --git a/tests/kubeconform.sh b/tests/kubeconform.sh index 4fb835dc..d380e2e5 100755 --- a/tests/kubeconform.sh +++ b/tests/kubeconform.sh @@ -11,7 +11,9 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" -KUBECONFORM_IMAGE="ghcr.io/yannh/kubeconform:v0.8.0@sha256:faffaf43f95aa6425306e1ab8d6fcad72acb9049158f38e574c085ea1ec0f64e" +# The pinned kubeconform image, where the CI jobs read it too. +KUBECONFORM_IMAGE=$(sed -n 's/^KUBECONFORM_IMAGE=//p' "${ROOT}/hack/ci-tools.env") +: "${KUBECONFORM_IMAGE:?hack/ci-tools.env has no KUBECONFORM_IMAGE}" WORK=$(mktemp -d) trap 'rm -rf "${WORK}"' EXIT diff --git a/tests/openai_stub.py b/tests/openai_stub.py index 0eb05060..e81120d2 100644 --- a/tests/openai_stub.py +++ b/tests/openai_stub.py @@ -14,6 +14,11 @@ the way vLLM's server ends a stream when its engine dies mid-decode. --cut-at N sends N tokens and closes the connection: no finish_reason, no usage, no [DONE] (a server process that is killed mid-stream; use with --close). + +For canary/run.sh it also serves the text completions API (POST <prefix>/completions, +plain and streamed, one chunk per word of --text) and GET <prefix>/models (503 with +--never-ready), /version (vLLM, --version) and /props (llama-server, --build-info). +--no-done ends a streamed completion without data: [DONE]. """ import argparse @@ -38,7 +43,55 @@ def send_chunk(self, data): self.wfile.write(f"{len(raw):x}\r\n".encode() + raw + b"\r\n") self.wfile.flush() + def send_json(self, code, obj): + raw = json.dumps(obj).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + def do_GET(self): + if self.path in [f"{x}/models" for x in opts.prefix.split(",")]: + if opts.never_ready: + self.send_json(503, {"error": "loading"}) + else: + self.send_json(200, {"object": "list", "data": [{"id": opts.model_name, "object": "model"}]}) + elif self.path == "/version" and opts.version: + self.send_json(200, {"version": opts.version}) + elif self.path == "/props" and opts.build_info: + self.send_json(200, {"build_info": opts.build_info, "total_slots": 1}) + else: + self.send_error(404) + + def completions(self, req): + words = opts.text.split(" ") + pieces = [w if i == 0 else " " + w for i, w in enumerate(words)] if opts.text else [] + pieces = pieces[:req.get("max_tokens") or len(pieces)] + usage = {"prompt_tokens": 5, "completion_tokens": len(pieces), "total_tokens": 5 + len(pieces)} + base = {"object": "text_completion", "created": int(time.time()), "model": req.get("model")} + if not req.get("stream"): + self.send_json(200, {**base, "choices": [{"index": 0, "text": "".join(pieces), "finish_reason": "length"}], + "usage": usage}) + return + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + if not opts.close: + self.send_header("Transfer-Encoding", "chunked") + self.end_headers() + for p in pieces: + self.send_chunk(json.dumps({**base, "choices": [{"index": 0, "text": p, "finish_reason": None}]})) + self.send_chunk(json.dumps({**base, "choices": [{"index": 0, "text": "", "finish_reason": "length"}], + "usage": usage})) + if not opts.no_done: + self.send_chunk("[DONE]") + if not opts.close: + self.wfile.write(b"0\r\n\r\n") + def do_POST(self): + if self.path in [f"{x}/completions" for x in opts.prefix.split(",")]: + self.completions(json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))))) + return if self.path not in [f"{x}/chat/completions" for x in opts.prefix.split(",")]: self.send_error(404) return @@ -98,6 +151,12 @@ def main(): p.add_argument("--die-at", type=int, default=0, help="send N tokens, then an error chunk and [DONE] (engine death)") p.add_argument("--cut-at", type=int, default=0, help="send N tokens, then close the connection (server killed)") p.add_argument("--prefix", default="/v1,/v3", help="API roots served, comma list") + p.add_argument("--text", default="tok0 tok1", help="text completions: the completion, one chunk per word") + p.add_argument("--model-name", default="canary", help="the id GET <prefix>/models lists") + p.add_argument("--version", default="", help="GET /version answers {version} (vLLM)") + p.add_argument("--build-info", default="", help="GET /props answers {build_info} (llama-server)") + p.add_argument("--never-ready", action="store_true", help="GET <prefix>/models answers 503 forever") + p.add_argument("--no-done", action="store_true", help="a streamed text completion ends without [DONE]") opts = p.parse_args() srv = http.server.ThreadingHTTPServer(("127.0.0.1", opts.port), make_handler(opts)) print(f"listening {srv.server_address[1]}", flush=True) diff --git a/tests/ops/fake_gh.py b/tests/ops/fake_gh.py new file mode 100755 index 00000000..27fa9f08 --- /dev/null +++ b/tests/ops/fake_gh.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""A stand-in for the gh CLI, for tests/ci_alarm_test.sh and tests/watchdog_test.sh. + +Install it on PATH as `gh` (a symlink or a one-line wrapper). State lives in the +directory $FAKE_GH: + + routes.tsv <regex> TAB <response file or -> TAB <exit code> [TAB <stderr text>] + The first line whose regex matches the arguments joined with single + spaces (re.search) answers: the file's bytes on stdout, the exit code, + the stderr text. Routes come before the built-in commands below, so a + route can make `issue list` fail the way a repository with issues + disabled does. Blank lines and lines starting with # are skipped. + issues.json the issue store the built-in issue commands read and write + labels.json label names `label create` added + calls.log every invocation, the arguments joined with single spaces + dispatches.log every `workflow run`: "<file> ref=<ref> [k=v ...]" + +Built in (shapes as gh 2.88 prints them for --json): + issue list [--label L] [--state open|closed|all] [--limit N] [--json F] + issue create --title T (--body B | --body-file F) [--label L]... [--assignee A]... + issue comment N (--body B | --body-file F) + issue edit N [--title T] [--body B | --body-file F] + issue close N [--comment C] [--reason R] + label create NAME [--force] [--color C] [--description D] + workflow run FILE [--ref R] [-f k=v]... + +FAKE_GH_NOW (ISO 8601, default 2026-10-29T13:00:00Z) stamps createdAt. +Anything else exits 1 with "fake gh: no route for: <args>" on stderr. +""" + +import json +import os +import re +import sys + +STATE = os.environ["FAKE_GH"] +NOW = os.environ.get("FAKE_GH_NOW", "2026-10-29T13:00:00Z") +REPO = os.environ.get("GH_REPO", "88plug/k3d-gpu") + + +def path(name): + return os.path.join(STATE, name) + + +def load(name, default): + try: + with open(path(name)) as f: + return json.load(f) + except FileNotFoundError: + return default + + +def save(name, obj): + with open(path(name), "w") as f: + json.dump(obj, f, indent=1) + + +def opts(args, multi=()): + """Parse --flag value pairs (and -R/-f/-F short forms) into a dict and positionals.""" + alias = {"-R": "--repo", "-f": "--raw-field", "-F": "--field", "-b": "--body", "-t": "--title", + "-l": "--label", "-a": "--assignee", "-L": "--limit", "-s": "--state", "-c": "--comment", + "-r": "--reason", "-d": "--description"} + flags = {"--force"} + out, pos, i = {}, [], 0 + while i < len(args): + a = alias.get(args[i], args[i]) + if a in flags: + out[a] = True + i += 1 + elif a.startswith("--") and "=" in a: + k, v = a.split("=", 1) + out.setdefault(k, []).append(v) if k in multi else out.__setitem__(k, v) + i += 1 + elif a.startswith("-") and i + 1 < len(args): + out.setdefault(a, []).append(args[i + 1]) if a in multi else out.__setitem__(a, args[i + 1]) + i += 2 + else: + pos.append(args[i]) + i += 1 + return out, pos + + +def body_of(o): + if "--body-file" in o: + with open(o["--body-file"]) as f: + return f.read() + return o.get("--body", "") + + +def find(issues, num): + for it in issues: + if it["number"] == int(num): + return it + sys.stderr.write("GraphQL: Could not resolve to an issue or pull request with the number of %s.\n" % num) + sys.exit(1) + + +def comment(it, body): + it["comments"].append({"author": {"login": "github-actions"}, "authorAssociation": "NONE", + "body": body, "createdAt": NOW}) + + +def builtin(args): + issues = load("issues.json", []) + if args[:2] == ["issue", "list"]: + o, _ = opts(args[2:]) + state = o.get("--state", "open") + out = [it for it in issues + if (state == "all" or it["state"] == state.upper()) + and ("--label" not in o or any(lb["name"] == o["--label"] for lb in it["labels"]))] + out.sort(key=lambda it: -it["number"]) + print(json.dumps(out[:int(o.get("--limit", 30))])) + return 0 + if args[:2] == ["issue", "create"]: + o, _ = opts(args[2:], multi=("--label", "--assignee")) + num = max([it["number"] for it in issues] + [0]) + 1 + issues.append({"number": num, "title": o["--title"], "body": body_of(o), "state": "OPEN", + "labels": [{"name": n} for n in o.get("--label", [])], + "assignees": [{"login": n} for n in o.get("--assignee", [])], + "comments": [], "createdAt": NOW}) + save("issues.json", issues) + print("https://github.com/%s/issues/%d" % (REPO, num)) + return 0 + if args[:2] == ["issue", "comment"]: + o, pos = opts(args[2:]) + comment(find(issues, pos[0]), body_of(o)) + save("issues.json", issues) + return 0 + if args[:2] == ["issue", "edit"]: + o, pos = opts(args[2:]) + it = find(issues, pos[0]) + if "--title" in o: + it["title"] = o["--title"] + if "--body" in o or "--body-file" in o: + it["body"] = body_of(o) + save("issues.json", issues) + return 0 + if args[:2] == ["issue", "close"]: + o, pos = opts(args[2:]) + it = find(issues, pos[0]) + if "--comment" in o: + comment(it, o["--comment"]) + it["state"] = "CLOSED" + save("issues.json", issues) + return 0 + if args[:2] == ["label", "create"]: + _, pos = opts(args[2:]) + labels = load("labels.json", []) + if pos[0] not in labels: + labels.append(pos[0]) + save("labels.json", labels) + return 0 + if args[:2] == ["workflow", "run"]: + o, pos = opts(args[2:], multi=("--raw-field", "--field")) + fields = o.get("--raw-field", []) + o.get("--field", []) + with open(path("dispatches.log"), "a") as f: + f.write(" ".join([pos[0], "ref=%s" % o.get("--ref", "")] + fields) + "\n") + return 0 + return None + + +def main(args): + line = " ".join(args) + with open(path("calls.log"), "a") as f: + f.write(line + "\n") + try: + with open(path("routes.tsv")) as f: + routes = [ln.rstrip("\n").split("\t") for ln in f if ln.strip() and not ln.startswith("#")] + except FileNotFoundError: + routes = [] + for r in routes: + if re.search(r[0], line): + if len(r) > 1 and r[1] not in ("", "-"): + with open(r[1] if os.path.isabs(r[1]) else path(r[1])) as f: + sys.stdout.write(f.read()) + if len(r) > 3: + sys.stderr.write(r[3] + "\n") + return int(r[2]) if len(r) > 2 and r[2] else 0 + rc = builtin(args) + if rc is None: + sys.stderr.write("fake gh: no route for: %s\n" % line) + return 1 + return rc + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tests/ops/fixtures/build-and-push_schedule_runs.json b/tests/ops/fixtures/build-and-push_schedule_runs.json new file mode 100644 index 00000000..bdf6d8ce --- /dev/null +++ b/tests/ops/fixtures/build-and-push_schedule_runs.json @@ -0,0 +1 @@ +[{"attempt":1,"conclusion":"success","createdAt":"2026-10-29T12:25:59Z","databaseId":40873203081,"displayTitle":"Auto-update, Build & Release","event":"schedule","headBranch":"main","headSha":"c2b649615dfc697c68236c847d5e93cff1928bff","name":"Auto-update, Build & Release","number":637,"startedAt":"2026-10-29T12:25:59Z","status":"completed","updatedAt":"2026-10-29T12:38:08Z","url":"https://github.com/88plug/k3d-gpu/actions/runs/40873203081","workflowName":"Auto-update, Build & Release"},{"attempt":1,"conclusion":"failure","createdAt":"2026-10-28T12:16:17Z","databaseId":40686436167,"displayTitle":"Auto-update, Build & Release","event":"schedule","headBranch":"main","headSha":"91e958f6ecfb2416a5dedf91eae516ca4cc5a52a","name":"Auto-update, Build & Release","number":632,"startedAt":"2026-10-28T12:16:17Z","status":"completed","updatedAt":"2026-10-28T12:28:45Z","url":"https://github.com/88plug/k3d-gpu/actions/runs/40686436167","workflowName":"Auto-update, Build & Release"},{"attempt":1,"conclusion":"failure","createdAt":"2026-10-27T11:43:34Z","databaseId":40498289109,"displayTitle":"Auto-update, Build & Release","event":"schedule","headBranch":"main","headSha":"91e958f6ecfb2416a5dedf91eae516ca4cc5a52a","name":"Auto-update, Build & Release","number":631,"startedAt":"2026-10-27T11:43:34Z","status":"completed","updatedAt":"2026-10-27T11:54:34Z","url":"https://github.com/88plug/k3d-gpu/actions/runs/40498289109","workflowName":"Auto-update, Build & Release"},{"attempt":1,"conclusion":"failure","createdAt":"2026-10-26T12:24:58Z","databaseId":40322356297,"displayTitle":"Auto-update, Build & Release","event":"schedule","headBranch":"main","headSha":"91e958f6ecfb2416a5dedf91eae516ca4cc5a52a","name":"Auto-update, Build & Release","number":630,"startedAt":"2026-10-26T12:24:58Z","status":"completed","updatedAt":"2026-10-26T12:37:33Z","url":"https://github.com/88plug/k3d-gpu/actions/runs/40322356297","workflowName":"Auto-update, Build & Release"},{"attempt":1,"conclusion":"failure","createdAt":"2026-10-25T10:54:11Z","databaseId":40186662031,"displayTitle":"Auto-update, Build & Release","event":"schedule","headBranch":"main","headSha":"91e958f6ecfb2416a5dedf91eae516ca4cc5a52a","name":"Auto-update, Build & Release","number":629,"startedAt":"2026-10-25T10:54:11Z","status":"completed","updatedAt":"2026-10-25T11:06:29Z","url":"https://github.com/88plug/k3d-gpu/actions/runs/40186662031","workflowName":"Auto-update, Build & Release"},{"attempt":1,"conclusion":"failure","createdAt":"2026-10-24T10:26:50Z","databaseId":40085941186,"displayTitle":"Auto-update, Build & Release","event":"schedule","headBranch":"main","headSha":"91e958f6ecfb2416a5dedf91eae516ca4cc5a52a","name":"Auto-update, Build & Release","number":628,"startedAt":"2026-10-24T10:26:50Z","status":"completed","updatedAt":"2026-10-24T10:37:26Z","url":"https://github.com/88plug/k3d-gpu/actions/runs/40085941186","workflowName":"Auto-update, Build & Release"},{"attempt":1,"conclusion":"failure","createdAt":"2026-10-23T11:09:08Z","databaseId":39944071889,"displayTitle":"Auto-update, Build & Release","event":"schedule","headBranch":"main","headSha":"91e958f6ecfb2416a5dedf91eae516ca4cc5a52a","name":"Auto-update, Build & Release","number":627,"startedAt":"2026-10-23T11:09:08Z","status":"completed","updatedAt":"2026-10-23T11:19:30Z","url":"https://github.com/88plug/k3d-gpu/actions/runs/39944071889","workflowName":"Auto-update, Build & Release"},{"attempt":1,"conclusion":"failure","createdAt":"2026-10-22T11:18:24Z","databaseId":39769577054,"displayTitle":"Auto-update, Build & Release","event":"schedule","headBranch":"main","headSha":"91e958f6ecfb2416a5dedf91eae516ca4cc5a52a","name":"Auto-update, Build & Release","number":626,"startedAt":"2026-10-22T11:18:24Z","status":"completed","updatedAt":"2026-10-22T11:29:23Z","url":"https://github.com/88plug/k3d-gpu/actions/runs/39769577054","workflowName":"Auto-update, Build & Release"},{"attempt":1,"conclusion":"failure","createdAt":"2026-10-21T11:19:14Z","databaseId":39591066580,"displayTitle":"Auto-update, Build & Release","event":"schedule","headBranch":"main","headSha":"ced8cd8729632d5d4fe8aeb3a89ae3b9232e7e1d","name":"Auto-update, Build & Release","number":620,"startedAt":"2026-10-21T11:19:14Z","status":"completed","updatedAt":"2026-10-21T11:29:11Z","url":"https://github.com/88plug/k3d-gpu/actions/runs/39591066580","workflowName":"Auto-update, Build & Release"},{"attempt":1,"conclusion":"failure","createdAt":"2026-10-20T11:02:22Z","databaseId":39412124435,"displayTitle":"Auto-update, Build & Release","event":"schedule","headBranch":"main","headSha":"a32a9701bf9bc139c1b38a5c46673462b26f66b2","name":"Auto-update, Build & Release","number":611,"startedAt":"2026-10-20T11:02:22Z","status":"completed","updatedAt":"2026-10-20T11:11:43Z","url":"https://github.com/88plug/k3d-gpu/actions/runs/39412124435","workflowName":"Auto-update, Build & Release"},{"attempt":1,"conclusion":"success","createdAt":"2026-10-19T12:24:18Z","databaseId":39251789909,"displayTitle":"Auto-update, Build & Release","event":"schedule","headBranch":"main","headSha":"3745468bc1be296deecd0f89203971cdb3bee5cb","name":"Auto-update, Build & Release","number":609,"startedAt":"2026-10-19T12:24:18Z","status":"completed","updatedAt":"2026-10-19T12:34:00Z","url":"https://github.com/88plug/k3d-gpu/actions/runs/39251789909","workflowName":"Auto-update, Build & Release"},{"attempt":1,"conclusion":"success","createdAt":"2026-10-18T10:31:50Z","databaseId":39119616480,"displayTitle":"Auto-update, Build & Release","event":"schedule","headBranch":"main","headSha":"5dae99b16c8e88d3f8c2bb8e0db552efa3c3b296","name":"Auto-update, Build & Release","number":605,"startedAt":"2026-10-18T10:31:50Z","status":"completed","updatedAt":"2026-10-18T10:40:21Z","url":"https://github.com/88plug/k3d-gpu/actions/runs/39119616480","workflowName":"Auto-update, Build & Release"},{"attempt":1,"conclusion":"success","createdAt":"2026-10-17T10:16:15Z","databaseId":39024257395,"displayTitle":"Auto-update, Build & Release","event":"schedule","headBranch":"main","headSha":"8b0526ef8ab52d247242fddb54c5f03a8c42c28d","name":"Auto-update, Build & Release","number":575,"startedAt":"2026-10-17T10:16:15Z","status":"completed","updatedAt":"2026-10-17T10:20:25Z","url":"https://github.com/88plug/k3d-gpu/actions/runs/39024257395","workflowName":"Auto-update, Build & Release"},{"attempt":1,"conclusion":"success","createdAt":"2026-10-16T10:53:26Z","databaseId":38890188335,"displayTitle":"Auto-update, Build & Release","event":"schedule","headBranch":"main","headSha":"83d9ce0686b71dcc49f4e43b55f4e859f2227f18","name":"Auto-update, Build & Release","number":560,"startedAt":"2026-10-16T10:53:26Z","status":"completed","updatedAt":"2026-10-16T10:53:41Z","url":"https://github.com/88plug/k3d-gpu/actions/runs/38890188335","workflowName":"Auto-update, Build & Release"}] diff --git a/tests/ops/fixtures/run_39412124435_jobs.json b/tests/ops/fixtures/run_39412124435_jobs.json new file mode 100644 index 00000000..499ec81b --- /dev/null +++ b/tests/ops/fixtures/run_39412124435_jobs.json @@ -0,0 +1,73 @@ +{ + "jobs": [ + { + "completedAt": "2026-10-20T11:11:42Z", + "conclusion": "failure", + "databaseId": 118592194165, + "name": "repin-images", + "startedAt": "2026-10-20T11:02:24Z", + "status": "completed", + "steps": [ + { + "completedAt": "2026-10-20T11:11:41Z", + "conclusion": "failure", + "name": "Open or update the digest PR", + "number": 7, + "startedAt": "2026-10-20T11:11:39Z", + "status": "completed" + } + ], + "url": "https://github.com/88plug/k3d-gpu/actions/runs/39412124435/job/118592194165" + }, + { + "completedAt": "2026-10-20T11:02:36Z", + "conclusion": "success", + "databaseId": 118592194452, + "name": "check", + "startedAt": "2026-10-20T11:02:25Z", + "status": "completed", + "steps": [], + "url": "https://github.com/88plug/k3d-gpu/actions/runs/39412124435/job/118592194452" + }, + { + "completedAt": "2026-10-20T11:02:31Z", + "conclusion": "success", + "databaseId": 118592194594, + "name": "list-components", + "startedAt": "2026-10-20T11:02:25Z", + "status": "completed", + "steps": [], + "url": "https://github.com/88plug/k3d-gpu/actions/runs/39412124435/job/118592194594" + }, + { + "completedAt": "2026-10-20T11:02:22Z", + "conclusion": "skipped", + "databaseId": 118592195801, + "name": "release", + "startedAt": "2026-10-20T11:02:23Z", + "status": "completed", + "steps": [], + "url": "https://github.com/88plug/k3d-gpu/actions/runs/39412124435/job/118592195801" + }, + { + "completedAt": "2026-10-20T11:11:24Z", + "conclusion": "success", + "databaseId": 118592253980, + "name": "bump-components (nri-balloons)", + "startedAt": "2026-10-20T11:02:33Z", + "status": "completed", + "steps": [], + "url": "https://github.com/88plug/k3d-gpu/actions/runs/39412124435/job/118592253980" + }, + { + "completedAt": "2026-10-20T11:11:26Z", + "conclusion": "success", + "databaseId": 118592254119, + "name": "bump-components (nri-balloons-guard)", + "startedAt": "2026-10-20T11:02:33Z", + "status": "completed", + "steps": [], + "url": "https://github.com/88plug/k3d-gpu/actions/runs/39412124435/job/118592254119" + } + ] +} diff --git a/tests/ops/fixtures/run_39412124435_log_failed.txt b/tests/ops/fixtures/run_39412124435_log_failed.txt new file mode 100644 index 00000000..0023334f --- /dev/null +++ b/tests/ops/fixtures/run_39412124435_log_failed.txt @@ -0,0 +1,69 @@ +repin-images UNKNOWN STEP 2026-10-20T11:11:40.3921272Z [command]/usr/bin/git checkout --progress -B autobump/image-digests 3dafeaa7-ed44-406a-adab-6b3079798909 -- +repin-images UNKNOWN STEP 2026-10-20T11:11:40.4155261Z Switched to a new branch 'autobump/image-digests' +repin-images UNKNOWN STEP 2026-10-20T11:11:40.4170852Z [command]/usr/bin/git rev-list --right-only --count main...autobump/image-digests +repin-images UNKNOWN STEP 2026-10-20T11:11:40.4213474Z 1 +repin-images UNKNOWN STEP 2026-10-20T11:11:40.4218208Z Created branch 'autobump/image-digests' +repin-images UNKNOWN STEP 2026-10-20T11:11:40.4223267Z [command]/usr/bin/git rev-parse main +repin-images UNKNOWN STEP 2026-10-20T11:11:40.4257673Z a32a9701bf9bc139c1b38a5c46673462b26f66b2 +repin-images UNKNOWN STEP 2026-10-20T11:11:40.4279469Z [command]/usr/bin/git -c core.quotePath=false show --raw --cc --no-renames --no-abbrev --format=%H%n%T%n%P%n%G?%n%s%n%b%n###EOB### a32a9701bf9bc139c1b38a5c46673462b26f66b2 +repin-images UNKNOWN STEP 2026-10-20T11:11:40.4736792Z [command]/usr/bin/git rev-parse autobump/image-digests +repin-images UNKNOWN STEP 2026-10-20T11:11:40.4824065Z 44df43843575c34e4445f74c19275b0a8c368127 +repin-images UNKNOWN STEP 2026-10-20T11:11:40.4828650Z [command]/usr/bin/git log --format=%H main..autobump/image-digests +repin-images UNKNOWN STEP 2026-10-20T11:11:40.4890744Z 44df43843575c34e4445f74c19275b0a8c368127 +repin-images UNKNOWN STEP 2026-10-20T11:11:40.4900873Z [command]/usr/bin/git -c core.quotePath=false show --raw --cc --no-renames --no-abbrev --format=%H%n%T%n%P%n%G?%n%s%n%b%n###EOB### 44df43843575c34e4445f74c19275b0a8c368127 +repin-images UNKNOWN STEP 2026-10-20T11:11:40.4945493Z [command]/usr/bin/git branch --delete --force 3dafeaa7-ed44-406a-adab-6b3079798909 +repin-images UNKNOWN STEP 2026-10-20T11:11:40.4985736Z Deleted branch 3dafeaa7-ed44-406a-adab-6b3079798909 (was 44df438). +repin-images UNKNOWN STEP 2026-10-20T11:11:40.4993338Z [command]/usr/bin/git checkout --progress main -- +repin-images UNKNOWN STEP 2026-10-20T11:11:40.5185041Z Switched to branch 'main' +repin-images UNKNOWN STEP 2026-10-20T11:11:40.5186587Z Your branch is up to date with 'origin/main'. +repin-images UNKNOWN STEP 2026-10-20T11:11:40.5195480Z ##[endgroup] +repin-images UNKNOWN STEP 2026-10-20T11:11:40.5196354Z ##[group]Pushing pull request branch to 'origin/autobump/image-digests' +repin-images UNKNOWN STEP 2026-10-20T11:11:40.5201405Z [command]/usr/bin/git push --force-with-lease origin autobump/image-digests:refs/heads/autobump/image-digests +repin-images UNKNOWN STEP 2026-10-20T11:11:41.5322777Z To https://github.com/88plug/k3d-gpu +repin-images UNKNOWN STEP 2026-10-20T11:11:41.5323814Z ! [remote rejected] autobump/image-digests -> autobump/image-digests (refusing to allow a GitHub App to create or update workflow `.github/workflows/ci.yml` without `workflows` permission) +repin-images UNKNOWN STEP 2026-10-20T11:11:41.5324928Z error: failed to push some refs to 'https://github.com/88plug/k3d-gpu' +repin-images UNKNOWN STEP 2026-10-20T11:11:41.5362780Z ##[error]The process '/usr/bin/git' failed with exit code 1 +repin-images UNKNOWN STEP 2026-10-20T11:11:41.5371492Z ##[group]Restore git configuration +repin-images UNKNOWN STEP 2026-10-20T11:11:41.5372069Z [command]/usr/bin/git config --local --name-only --get-regexp http.https://github.com/.extraheader ^AUTHORIZATION: +repin-images UNKNOWN STEP 2026-10-20T11:11:41.5391040Z http.https://github.com/.extraheader +repin-images UNKNOWN STEP 2026-10-20T11:11:41.5401977Z [command]/usr/bin/git config --local --get-regexp http.https://github.com/.extraheader ^AUTHORIZATION: +repin-images UNKNOWN STEP 2026-10-20T11:11:41.5444012Z http.https://github.com/.extraheader AUTHORIZATION: basic *** +repin-images UNKNOWN STEP 2026-10-20T11:11:41.5449266Z [command]/usr/bin/git config --local --unset http.https://github.com/.extraheader ^AUTHORIZATION: +repin-images UNKNOWN STEP 2026-10-20T11:11:41.5489680Z Unset config key 'http.https://github.com/.extraheader' +repin-images UNKNOWN STEP 2026-10-20T11:11:41.5493641Z Restored checkout credential file: git-credentials-0855f328-330d-4402-b0df-e8084e6fb813.config +repin-images UNKNOWN STEP 2026-10-20T11:11:41.5497799Z [command]/usr/bin/git config --global --unset safe.directory /home/runner/work/k3d-gpu/k3d-gpu +repin-images UNKNOWN STEP 2026-10-20T11:11:41.5542247Z ##[endgroup] +repin-images UNKNOWN STEP 2026-10-20T11:11:41.5777709Z Post job cleanup. +repin-images UNKNOWN STEP 2026-10-20T11:11:41.6720620Z [command]/usr/bin/git version +repin-images UNKNOWN STEP 2026-10-20T11:11:41.6770976Z git version 2.55.0 +repin-images UNKNOWN STEP 2026-10-20T11:11:41.6810419Z Copying '/home/runner/.gitconfig' to '/home/runner/work/_temp/61145c41-06b6-42b8-b3ad-72172d8eeb20/.gitconfig' +repin-images UNKNOWN STEP 2026-10-20T11:11:41.6821283Z Temporarily overriding HOME='/home/runner/work/_temp/61145c41-06b6-42b8-b3ad-72172d8eeb20' before making global git config changes +repin-images UNKNOWN STEP 2026-10-20T11:11:41.6822668Z Adding repository directory to the temporary git global config as a safe directory +repin-images UNKNOWN STEP 2026-10-20T11:11:41.6827043Z [command]/usr/bin/git config --global --add safe.directory /home/runner/work/k3d-gpu/k3d-gpu +repin-images UNKNOWN STEP 2026-10-20T11:11:41.6868727Z Removing SSH command configuration +repin-images UNKNOWN STEP 2026-10-20T11:11:41.6876124Z [command]/usr/bin/git config --local --name-only --get-regexp core\.sshCommand +repin-images UNKNOWN STEP 2026-10-20T11:11:41.6918380Z [command]/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'core\.sshCommand' && git config --local --unset-all 'core.sshCommand' || :" +repin-images UNKNOWN STEP 2026-10-20T11:11:41.7214198Z Removing HTTP extra header +repin-images UNKNOWN STEP 2026-10-20T11:11:41.7221090Z [command]/usr/bin/git config --local --name-only --get-regexp http\.https\:\/\/github\.com\/\.extraheader +repin-images UNKNOWN STEP 2026-10-20T11:11:41.7263494Z [command]/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'http\.https\:\/\/github\.com\/\.extraheader' && git config --local --unset-all 'http.https://github.com/.extraheader' || :" +repin-images UNKNOWN STEP 2026-10-20T11:11:41.7561114Z Removing includeIf entries pointing to credentials config files +repin-images UNKNOWN STEP 2026-10-20T11:11:41.7573853Z [command]/usr/bin/git config --local --name-only --get-regexp ^includeIf\.gitdir: +repin-images UNKNOWN STEP 2026-10-20T11:11:41.7609505Z includeif.gitdir:/home/runner/work/k3d-gpu/k3d-gpu/.git.path +repin-images UNKNOWN STEP 2026-10-20T11:11:41.7610347Z includeif.gitdir:/home/runner/work/k3d-gpu/k3d-gpu/.git/worktrees/*.path +repin-images UNKNOWN STEP 2026-10-20T11:11:41.7611064Z includeif.gitdir:/github/workspace/.git.path +repin-images UNKNOWN STEP 2026-10-20T11:11:41.7611707Z includeif.gitdir:/github/workspace/.git/worktrees/*.path +repin-images UNKNOWN STEP 2026-10-20T11:11:41.7620469Z [command]/usr/bin/git config --local --get-all includeif.gitdir:/home/runner/work/k3d-gpu/k3d-gpu/.git.path +repin-images UNKNOWN STEP 2026-10-20T11:11:41.7652746Z /home/runner/work/_temp/git-credentials-0855f328-330d-4402-b0df-e8084e6fb813.config +repin-images UNKNOWN STEP 2026-10-20T11:11:41.7665336Z [command]/usr/bin/git config --local --unset includeif.gitdir:/home/runner/work/k3d-gpu/k3d-gpu/.git.path /home/runner/work/_temp/git-credentials-0855f328-330d-4402-b0df-e8084e6fb813.config +repin-images UNKNOWN STEP 2026-10-20T11:11:41.7719090Z [command]/usr/bin/git config --local --get-all includeif.gitdir:/home/runner/work/k3d-gpu/k3d-gpu/.git/worktrees/*.path +repin-images UNKNOWN STEP 2026-10-20T11:11:41.7754639Z /home/runner/work/_temp/git-credentials-0855f328-330d-4402-b0df-e8084e6fb813.config +repin-images UNKNOWN STEP 2026-10-20T11:11:41.7767248Z [command]/usr/bin/git config --local --unset includeif.gitdir:/home/runner/work/k3d-gpu/k3d-gpu/.git/worktrees/*.path /home/runner/work/_temp/git-credentials-0855f328-330d-4402-b0df-e8084e6fb813.config +repin-images UNKNOWN STEP 2026-10-20T11:11:41.7817451Z [command]/usr/bin/git config --local --get-all includeif.gitdir:/github/workspace/.git.path +repin-images UNKNOWN STEP 2026-10-20T11:11:41.7854260Z /github/runner_temp/git-credentials-0855f328-330d-4402-b0df-e8084e6fb813.config +repin-images UNKNOWN STEP 2026-10-20T11:11:41.7864827Z [command]/usr/bin/git config --local --unset includeif.gitdir:/github/workspace/.git.path /github/runner_temp/git-credentials-0855f328-330d-4402-b0df-e8084e6fb813.config +repin-images UNKNOWN STEP 2026-10-20T11:11:41.7914916Z [command]/usr/bin/git config --local --get-all includeif.gitdir:/github/workspace/.git/worktrees/*.path +repin-images UNKNOWN STEP 2026-10-20T11:11:41.7949826Z /github/runner_temp/git-credentials-0855f328-330d-4402-b0df-e8084e6fb813.config +repin-images UNKNOWN STEP 2026-10-20T11:11:41.7962282Z [command]/usr/bin/git config --local --unset includeif.gitdir:/github/workspace/.git/worktrees/*.path /github/runner_temp/git-credentials-0855f328-330d-4402-b0df-e8084e6fb813.config +repin-images UNKNOWN STEP 2026-10-20T11:11:41.8011711Z [command]/usr/bin/git submodule foreach --recursive git config --local --show-origin --name-only --get-regexp remote.origin.url +repin-images UNKNOWN STEP 2026-10-20T11:11:41.8271962Z Removing credentials config '/home/runner/work/_temp/git-credentials-0855f328-330d-4402-b0df-e8084e6fb813.config' +repin-images UNKNOWN STEP 2026-10-20T11:11:41.8452430Z Cleaning up orphan processes diff --git a/tests/ops/fixtures/workflows.json b/tests/ops/fixtures/workflows.json new file mode 100644 index 00000000..d527afcf --- /dev/null +++ b/tests/ops/fixtures/workflows.json @@ -0,0 +1 @@ +{"total_count":9,"workflows":[{"id":284520033,"node_id":"W_kwDOOd6rPM4Q9W5h","name":"AUR Deploy","path":".github/workflows/aur-deploy.yml","state":"active","created_at":"2026-05-27T15:37:24.000-07:00","updated_at":"2026-05-27T15:37:24.000-07:00","url":"https://api.github.com/repos/88plug/k3d-gpu/actions/workflows/284520033","html_url":"https://github.com/88plug/k3d-gpu/blob/main/.github/workflows/aur-deploy.yml","badge_url":"https://github.com/88plug/k3d-gpu/workflows/AUR%20Deploy/badge.svg"},{"id":284520036,"node_id":"W_kwDOOd6rPM4Q9W5k","name":"Auto-release AUR","path":".github/workflows/auto-release.yml","state":"active","created_at":"2026-05-27T15:37:24.000-07:00","updated_at":"2026-05-27T15:37:24.000-07:00","url":"https://api.github.com/repos/88plug/k3d-gpu/actions/workflows/284520036","html_url":"https://github.com/88plug/k3d-gpu/blob/main/.github/workflows/auto-release.yml","badge_url":"https://github.com/88plug/k3d-gpu/workflows/Auto-release%20AUR/badge.svg"},{"id":157632137,"node_id":"W_kwDOOd6rPM4JZUaJ","name":"Auto-update, Build & Release","path":".github/workflows/build-and-push.yml","state":"active","created_at":"2025-04-22T13:32:16.000-07:00","updated_at":"2025-04-22T13:43:51.000-07:00","url":"https://api.github.com/repos/88plug/k3d-gpu/actions/workflows/157632137","html_url":"https://github.com/88plug/k3d-gpu/blob/main/.github/workflows/build-and-push.yml","badge_url":"https://github.com/88plug/k3d-gpu/workflows/Auto-update,%20Build%20&%20Release/badge.svg"},{"id":284520038,"node_id":"W_kwDOOd6rPM4Q9W5m","name":"CI","path":".github/workflows/ci.yml","state":"active","created_at":"2026-05-27T15:37:24.000-07:00","updated_at":"2026-05-27T15:37:24.000-07:00","url":"https://api.github.com/repos/88plug/k3d-gpu/actions/workflows/284520038","html_url":"https://github.com/88plug/k3d-gpu/blob/main/.github/workflows/ci.yml","badge_url":"https://github.com/88plug/k3d-gpu/workflows/CI/badge.svg"},{"id":284520040,"node_id":"W_kwDOOd6rPM4Q9W5o","name":"Auto-merge Dependabot PRs","path":".github/workflows/dependabot-auto-merge.yml","state":"active","created_at":"2026-05-27T15:37:24.000-07:00","updated_at":"2026-05-27T15:37:24.000-07:00","url":"https://api.github.com/repos/88plug/k3d-gpu/actions/workflows/284520040","html_url":"https://github.com/88plug/k3d-gpu/blob/main/.github/workflows/dependabot-auto-merge.yml","badge_url":"https://github.com/88plug/k3d-gpu/workflows/Auto-merge%20Dependabot%20PRs/badge.svg"},{"id":387399486,"node_id":"W_kwDOOd6rPM4XFz8-","name":"Docs","path":".github/workflows/docs.yml","state":"active","created_at":"2026-10-16T22:44:34.000-07:00","updated_at":"2026-10-16T22:48:54.000-07:00","url":"https://api.github.com/repos/88plug/k3d-gpu/actions/workflows/387399486","html_url":"https://github.com/88plug/k3d-gpu/blob/main/.github/workflows/docs.yml","badge_url":"https://github.com/88plug/k3d-gpu/workflows/Docs/badge.svg"},{"id":387412638,"node_id":"W_kwDOOd6rPM4XF3Ke","name":"Release packages (deb, rpm, Homebrew)","path":".github/workflows/packages.yml","state":"active","created_at":"2026-10-16T23:12:09.000-07:00","updated_at":"2026-10-16T23:12:09.000-07:00","url":"https://api.github.com/repos/88plug/k3d-gpu/actions/workflows/387412638","html_url":"https://github.com/88plug/k3d-gpu/blob/main/.github/workflows/packages.yml","badge_url":"https://github.com/88plug/k3d-gpu/workflows/Release%20packages%20(deb,%20rpm,%20Homebrew)/badge.svg"},{"id":231869215,"node_id":"W_kwDOOd6rPM4N0gsf","name":"Copilot coding agent","path":"dynamic/copilot-swe-agent/copilot","state":"active","created_at":"2026-02-08T09:15:33.000-08:00","updated_at":"2026-02-08T09:15:33.000-08:00","url":"https://api.github.com/repos/88plug/k3d-gpu/actions/workflows/231869215","html_url":"https://github.com/88plug/k3d-gpu/actions/workflows/copilot-swe-agent/copilot","badge_url":"https://github.com/88plug/k3d-gpu/actions/workflows/copilot-swe-agent/copilot/badge.svg"},{"id":284520057,"node_id":"W_kwDOOd6rPM4Q9W55","name":"CodeQL","path":"dynamic/github-code-scanning/codeql","state":"active","created_at":"2026-05-27T15:37:28.000-07:00","updated_at":"2026-05-27T15:37:28.000-07:00","url":"https://api.github.com/repos/88plug/k3d-gpu/actions/workflows/284520057","html_url":"https://github.com/88plug/k3d-gpu/actions/workflows/github-code-scanning/codeql","badge_url":"https://github.com/88plug/k3d-gpu/actions/workflows/github-code-scanning/codeql/badge.svg"}]} \ No newline at end of file diff --git a/tests/ops/stub_registry.py b/tests/ops/stub_registry.py new file mode 100644 index 00000000..4a9f5e8d --- /dev/null +++ b/tests/ops/stub_registry.py @@ -0,0 +1,24 @@ +"""A stand-in for scripts/images-lock's registry_digest, for tests/watchdog_test.sh. + +hack/watchdog loads registry_digest from the file WATCHDOG_IMAGES_LOCK names; the +test points that at this file. $STUB_REGISTRY names a file of lines +"<name>:<tag> [status]": a listed reference without a status resolves to a fixed +digest, one with a status (e.g. 503) raises the way images-lock does for that HTTP +status, and an unlisted one raises HTTP 404. Every lookup is appended to +$STUB_REGISTRY.log. +""" + +import os + + +def registry_digest(name, tag): + ref = "%s:%s" % (name, tag) + with open(os.environ["STUB_REGISTRY"] + ".log", "a") as f: + f.write(ref + "\n") + with open(os.environ["STUB_REGISTRY"]) as f: + rows = dict((ln.split() + [""])[:2] for ln in f if ln.strip()) + if ref not in rows: + raise ValueError("%s: HTTP 404 from https://stub/v2/%s/manifests/%s" % (ref, name, tag)) + if rows[ref]: + raise ValueError("%s: HTTP %s from https://stub/v2/%s/manifests/%s" % (ref, rows[ref], name, tag)) + return "sha256:" + "0" * 64 diff --git a/tests/ops/workflow_checks.py b/tests/ops/workflow_checks.py new file mode 100755 index 00000000..da0a2ef0 --- /dev/null +++ b/tests/ops/workflow_checks.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""Workflow checks for tests/ci_wiring_test.sh (alarm, watchdog, release hardening). + +Reads every .github/workflows/*.yml with PyYAML and prints one line per check: +"ok <what>" or "FAIL <what>: <why>". Exit 0 when the checks ran (failures are +lines, not the exit code). + +Usage: tests/ops/workflow_checks.py REPO_ROOT +""" + +import glob +import os +import re +import subprocess +import sys +import tempfile + +import yaml + +ROOT = sys.argv[1] +WF = os.path.join(ROOT, ".github", "workflows") +DOCS = {os.path.basename(p): yaml.safe_load(open(p)) for p in sorted(glob.glob(WF + "/*.yml"))} + + +def say(cond, what, why=""): + print(("ok %s" % what) if cond else ("FAIL %s%s" % (what, (": " + str(why)) if why else ""))) + + +def on(d): + """A workflow's on: block (PyYAML reads the bare key `on` as True).""" + v = (d or {}).get("on", (d or {}).get(True)) + return v if isinstance(v, dict) else {} + + +# Every job has timeout-minutes; every workflow has a top-level permissions block. +for f, d in DOCS.items(): + missing = [j for j, v in (d.get("jobs") or {}).items() if not isinstance(v.get("timeout-minutes"), int)] + say(not missing, "%s: every job has timeout-minutes" % f, "missing in %s" % ", ".join(missing)) + say("permissions" in d, "%s: has a top-level permissions block" % f) +say(DOCS.get("aur-deploy.yml", {}).get("permissions") == {"contents": "read"}, "aur-deploy.yml: permissions contents: read") +# Releases now build in parallel, so the AUR deploy of an older one can come last: +# it must leave a newer AUR version alone. +ad = ((DOCS.get("aur-deploy.yml") or {}).get("jobs") or {}).get("deploy", {}).get("steps") or [] +guard = [s for s in ad if s.get("id") == "aur"] +pub = [s for s in ad if str(s.get("uses", "")).startswith("KSXGitHub/github-actions-deploy-aur@")] +say(guard and "sort -V" in guard[0].get("run", "") and "rpc/v5/info" in guard[0].get("run", "") + and pub and pub[0].get("if") == "steps.aur.outputs.skip != 'true'" + and ad.index(guard[0]) < ad.index(pub[0]), + "aur-deploy.yml never moves AUR back to an older version") + + +def aur_guard(answer, ver): + """Run the guard step as Actions does (bash -eo pipefail) against a fake curl: + answer is the AUR version (a JSON reply) or None (curl fails). Returns + (exit code, GITHUB_OUTPUT text).""" + with tempfile.TemporaryDirectory() as d: + reply = '{"resultcount":1,"results":[{"Version":"%s"}]}' % answer if answer else "" + with open(os.path.join(d, "curl"), "w") as f: + f.write("#!/bin/sh\n" + ("printf '%%s\\n' '%s'\n" % reply if answer else "exit 22\n")) + os.chmod(os.path.join(d, "curl"), 0o755) + out = os.path.join(d, "out") + open(out, "w").close() + env = dict(os.environ, PATH=d + os.pathsep + os.environ["PATH"], VER=ver, GITHUB_OUTPUT=out) + p = subprocess.run(["bash", "--noprofile", "--norc", "-eo", "pipefail", "-c", guard[0]["run"]], + env=env, capture_output=True, text=True) + return p.returncode, open(out).read() + + +if guard: + got = {k: aur_guard(a, "0.1.80") for k, a in + (("newer", "0.1.81-1"), ("same", "0.1.80-1"), ("older", "0.1.79-1"), ("unreadable", None))} + say(got["newer"] == (0, "skip=true\n") and got["same"] == (0, "") and got["older"] == (0, "") + and got["unreadable"] == (0, ""), + "aur-deploy.yml AUR guard: skips only for a newer AUR version; an unreadable RPC deploys", got) + +# No cron fires at minute 0 or 30. +scheduled = {} +for f, d in DOCS.items(): + for c in on(d).get("schedule") or []: + scheduled.setdefault(f, []).append(c["cron"]) + minute = c["cron"].split()[0] + bad = not re.fullmatch(r"\d+(,\d+)*", minute) or bool({0, 30} & {int(x) for x in minute.split(",")}) + say(not bad, "%s: cron '%s' avoids minutes 0 and 30" % (f, c["cron"])) + +# hack/watchdog discovers every scheduled workflow. +p = subprocess.run([sys.executable, os.path.join(ROOT, "hack", "watchdog"), "--list-scheduled", "--root", ROOT], + capture_output=True, text=True) +found = {ln.split()[0] for ln in p.stdout.splitlines() if ln.strip()} +say(p.returncode == 0 and scheduled and found == set(scheduled), + "hack/watchdog --list-scheduled covers every scheduled workflow", + "watchdog %s, yaml %s %s" % (sorted(found), sorted(scheduled), p.stderr[-200:])) + +# watchdog.yml listens to CI, Docs and the release chain by their exact names, and +# to Canary, Freshness and Bot sweep once those workflows exist. +names = {d.get("name") for d in DOCS.values()} +wd = DOCS.get("watchdog.yml") or {} +listed = (on(wd).get("workflow_run") or {}).get("workflows") or [] +want = {"CI", "Auto-update, Build & Release", "Auto-release AUR", "Release packages (deb, rpm, Homebrew)", + "AUR Deploy", "Docs"} | ({"Canary", "Freshness", "Bot sweep"} & names) +say(set(listed) == want and len(listed) == len(want) and set(listed) <= names, + "watchdog.yml workflow_run names equal the name: fields of the workflows it watches", + "listed %s, want %s, no such workflow %s" % (sorted(listed), sorted(want), sorted(set(listed) - names))) +job = (wd.get("jobs") or {}).get("watchdog") or {} +perms = wd.get("permissions") or {} +say(perms.get("issues") == "write" and perms.get("actions") == "write" and perms.get("contents") == "read" + and wd.get("concurrency") == {"group": "watchdog", "cancel-in-progress": False} + and "head_branch == 'main'" in str(job.get("if", "")) + and (on(wd).get("workflow_run") or {}).get("branches") == ["main"] + and on(wd).get("schedule") == [{"cron": "47 * * * *"}] + and ((on(wd).get("workflow_dispatch") or {}).get("inputs") or {}).get("simulate_red", {}).get("type") == "boolean", + "watchdog.yml: hourly at :47, simulate_red, issues+actions write, one queue, workflow_run on main only") +# workflow_run's branches: filter matches a fork's branch named main too (a fork PR +# from <fork>:main), so the job also requires the run to come from this repository. +say("github.event.workflow_run.head_repository.full_name == github.repository" in str(job.get("if", "")), + "watchdog.yml: a workflow_run from a fork's main does not start the watchdog", job.get("if")) +co = [s for s in job.get("steps", []) if str(s.get("uses", "")).startswith("actions/checkout@")] +say(co and co[0].get("with", {}).get("ref") == "main" and co[0]["with"].get("fetch-depth") == 0 + and any(str(s.get("run", "")).strip() == "hack/watchdog" for s in job.get("steps", [])), + "watchdog.yml checks out main with every tag and runs hack/watchdog") + +# build-and-push.yml: one queue per release version, the nightly in its own, so a +# pending release never replaces another. +bp = DOCS.get("build-and-push.yml") or {} +say(bp.get("concurrency") == {"group": "build-and-push-${{ inputs.release && format('release-{0}', inputs.release) || 'nightly' }}", + "cancel-in-progress": False}, + "build-and-push.yml concurrency is keyed by the release version", bp.get("concurrency")) + +# The release job builds each variant in up to 3 identical attempts (1 and 2 +# continue on error and are followed by a 2 and a 4 min wait; 3 fails the job), +# pushing the versioned tags only; the floating tags are copied after both builds +# from hack/release-tags run at that moment. +steps = ((bp.get("jobs") or {}).get("release") or {}).get("steps") or [] +by_id = {s.get("id"): s for s in steps if s.get("id")} +order = [s.get("id") or s.get("name") for s in steps] +for v in ("2604", "2404"): + a = [by_id.get("b%s_%d" % (v, i)) for i in (1, 2, 3)] + good = all(a) and all(str(s.get("uses", "")).startswith("docker/build-push-action@") for s in a) + good = good and [s.get("continue-on-error") for s in a] == [True, True, None] + good = good and a[0].get("if") is None and a[1].get("if") == "steps.b%s_1.outcome == 'failure'" % v \ + and a[2].get("if") == "steps.b%s_2.outcome == 'failure'" % v + good = good and all(s["with"].get("tags") == "${{ steps.tags.outputs.v%s }}" % v for s in a) + good = good and len({yaml.safe_dump(s["with"]) for s in a}) == 1 and len({s.get("uses") for s in a}) == 1 + waits = [s for s in steps if "sleep" in str(s.get("run", "")) and s.get("if") in + ("steps.b%s_1.outcome == 'failure'" % v, "steps.b%s_2.outcome == 'failure'" % v)] + good = good and [w["run"].split()[-1] for w in waits] == ["120", "240"] + good = good and order.index("b%s_1" % v) < order.index(waits[0].get("id") or waits[0].get("name")) \ + < order.index("b%s_2" % v) < order.index(waits[1].get("id") or waits[1].get("name")) < order.index("b%s_3" % v) + say(good, "release builds the %s variant in 3 identical attempts, 2 and 4 min apart, versioned tags only" % v) +tags = by_id.get("tags") or {} +flo = [s for s in steps if "imagetools create" in str(s.get("run", ""))] +say("release-tags" in str(tags.get("run", "")) and len(flo) == 1 and "release-tags" in flo[0]["run"] + and "floating" in flo[0]["run"] and "b2404_3" in order and "b2604_3" in order + and order.index(flo[0].get("id") or flo[0].get("name")) > max(order.index("b2404_3"), order.index("b2604_3")), + "floating tags come from hack/release-tags at push time, after both builds") +say(any("already published" in str(s.get("run", "")) for s in steps), "release keeps its refusal to overwrite a published version") + +# auto-release.yml: its push paths equal hack/release-paths, which covers every file +# nfpm and the Homebrew formula ship (README and license excepted); mode=changed +# runs release_cas.sh as a push, so the path filter applies. +ar = DOCS.get("auto-release.yml") or {} +rp = [ln.split("#")[0].strip() for ln in open(os.path.join(ROOT, "hack", "release-paths"))] +rp = [x for x in rp if x] +say(rp and all(re.fullmatch(r"[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)*(/\*\*)?", x) for x in rp), + "hack/release-paths holds only paths and dir/** entries") +say((on(ar).get("push") or {}).get("paths") == rp, "auto-release.yml paths: equals hack/release-paths", + "yml %s, file %s" % ((on(ar).get("push") or {}).get("paths"), rp)) + + +def covered(src): + return any(src == r or (r.endswith("/**") and src.startswith(r[:-2])) for r in rp) + + +nfpm = yaml.safe_load(open(os.path.join(ROOT, "packaging", "nfpm", "nfpm.yaml"))) +srcs = [c["src"] for c in nfpm["contents"] if c.get("src")] +formula = open(os.path.join(ROOT, "Formula", "k3d-gpu.rb")).read() +inst = re.sub(r",\s*\n\s*", ", ", formula.split("def install", 1)[1].split("\n end", 1)[0]) +fsrcs = [q for ln in inst.splitlines() if ".install" in ln for q in re.findall(r'"([^"]+)"', ln.split(".install", 1)[1])] +docs_only = {"README.md", "LICENSE.md"} +miss = [x for x in srcs + fsrcs if x not in docs_only and not covered(x)] +say(bool(srcs) and bool(fsrcs) and not miss, "hack/release-paths covers every nfpm src and Formula source", + "not covered: %s" % miss) +mode = ((on(ar).get("workflow_dispatch") or {}).get("inputs") or {}).get("mode") or {} +say(mode.get("type") == "choice" and mode.get("options") == ["force", "changed"] and mode.get("default") == "force", + "auto-release.yml workflow_dispatch input mode: force (default) or changed", mode) +rel = (ar.get("jobs") or {}).get("release") or {} +ev = [(s.get("env") or {}).get("EVENT") for s in rel.get("steps", []) if "release_cas.sh" in str(s.get("run", ""))] +say(ev == ["${{ github.event_name == 'workflow_dispatch' && inputs.mode == 'changed' && 'push' || github.event_name }}"], + "mode=changed runs release_cas.sh as a push (the path filter applies)", "EVENT=%s" % ev) +grp = ((rel.get("concurrency") or {}).get("group") or "") +say("inputs.mode" in grp and rel.get("concurrency", {}).get("cancel-in-progress") is False, + "auto-release.yml queues each mode separately (a pending forced release is never replaced)", grp) diff --git a/tests/pin_classes_test.py b/tests/pin_classes_test.py new file mode 100644 index 00000000..d712aca0 --- /dev/null +++ b/tests/pin_classes_test.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +"""Tests for hack/pin-classes: the pins/classes parser, first-match order, the +effective class against a fixture ledger, pin tokens, the CLI, and the real repo +(coverage passes, every historical-ref string is present, the finished runners +resolve to historical and the pending rows' runners to arm). + +Usage: python3 tests/pin_classes_test.py +""" + +import importlib.machinery +import importlib.util +import os +import pathlib +import subprocess +import sys +import tempfile +import unittest + +ROOT = pathlib.Path(__file__).resolve().parent.parent +TOOL = ROOT / "hack" / "pin-classes" +_loader = importlib.machinery.SourceFileLoader("pin_classes", str(TOOL)) +pc = importlib.util.module_from_spec(importlib.util.spec_from_loader("pin_classes", _loader)) +_loader.exec_module(pc) + +LEDGER = """# Experiments + +## Hypotheses + +| # | Hypothesis | Prediction | Verdict | +|---|------------|------------|---------| +| H1 | one | p | PENDING | +| H2 | two | p | **REFUTED** | +| H3 | three | p | SUPPORTED | +""" + +CLASSES = """# a comment +bench/x/old.sh historical the first run's runner +bench/x/** ledger H1,H2 amend=Fam H1 and H2's runner +bench/done/** ledger H2,H3 amend= finished rows +bench/new/** ledger H7 amend=H7 a row not in the table yet +bench/tool.py ours +integrations/eat/** eat-stock EAT's values +historical-ref bench/x/run.sh OLD=foo/bar:1@sha256:aaaa +""" + +DIGEST = "sha256:" + "a" * 64 + + +def write(root, files): + for rel, text in files.items(): + p = pathlib.Path(root, rel) + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(text) + + +def cli(*args, root=ROOT): + p = subprocess.run([sys.executable, str(TOOL), "--root", str(root)] + list(args), capture_output=True, text=True) + return p.returncode, p.stdout + p.stderr + + +class Parse(unittest.TestCase): + def test_kinds_and_first_match(self): + lines, refs = pc.parse(CLASSES) + self.assertEqual([ln.kind for ln in lines], ["historical", "ledger", "ledger", "ledger", "ours", "eat-stock"]) + self.assertEqual(pc.match(lines, "bench/x/old.sh").kind, "historical") # before bench/x/** + self.assertEqual(pc.match(lines, "bench/x/run.sh").rows, ("H1", "H2")) + self.assertEqual(pc.match(lines, "bench/x/run.sh").aliases, ("Fam",)) + self.assertEqual(pc.match(lines, "bench/done/a/b.py").aliases, ()) + self.assertEqual(pc.match(lines, "bench/x/deep/er.sh").glob, "bench/x/**") + self.assertIsNone(pc.match(lines, "bench/xy/run.sh")) + self.assertEqual([(r.glob, r.ref) for r in refs], [("bench/x/run.sh", "OLD=foo/bar:1@sha256:aaaa")]) + + def test_errors(self): + for bad in ("bench/a.sh frozen", "bench/a.sh ledger H1", "bench/a.sh ledger X1 amend=A", + "historical-ref bench/a.sh", "bench/a.sh"): + with self.subTest(bad=bad), self.assertRaises(ValueError): + pc.parse(bad + "\n") + + def test_historical_ref_keeps_spaces(self): + _, refs = pc.parse('historical-ref bench/h.py "k": "v w"\n') + self.assertEqual(refs[0].ref, '"k": "v w"') + + +class Effective(unittest.TestCase): + def setUp(self): + self.d = tempfile.TemporaryDirectory() + write(self.d.name, {"EXPERIMENTS.md": LEDGER}) + self.ledger = pc.read_ledger(self.d.name) + self.lines, _ = pc.parse(CLASSES) + + def tearDown(self): + self.d.cleanup() + + def eff(self, path, text=None): + return pc.effective(self.lines, self.ledger, path, text)[0] + + def test_pending_row_is_arm(self): + self.assertEqual(self.eff("bench/x/run.sh"), "arm") + + def test_verdicts_only_is_historical(self): + self.assertEqual(self.eff("bench/done/run.sh"), "historical") + + def test_verdict_landing_flips_to_historical(self): + write(self.d.name, {"EXPERIMENTS.md": LEDGER.replace("| p | PENDING |", "| p | REFUTED |")}) + ledger = pc.read_ledger(self.d.name) + self.assertEqual(pc.effective(self.lines, ledger, "bench/x/run.sh")[0], "historical") + + def test_rows_not_in_table_yet_are_ours(self): + self.assertEqual(self.eff("bench/new/run.sh"), "ours") + + def test_plain_kinds_and_defaults(self): + self.assertEqual(self.eff("bench/x/old.sh"), "historical") + self.assertEqual(self.eff("bench/tool.py"), "ours") + self.assertEqual(self.eff("integrations/eat/v.yaml"), "eat-stock") + self.assertEqual(self.eff("scripts/k3d-gpu", "IMAGE=foo/bar:1@" + DIGEST), "ours") + self.assertEqual(self.eff("bench/other/run.sh", "echo hello"), "ours") + self.assertEqual(self.eff("bench/other/run.sh", "IMG=ghcr.io/foo/bar:1.2"), "unclassified") + self.assertEqual(self.eff("oracle/x.py", 'P = "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678"'), "unclassified") + + +class Tokens(unittest.TestCase): + def test_tokens(self): + cases = { + "image: ghcr.io/foo/bar:v1.2@" + DIGEST: ["ghcr.io/foo/bar:v1.2@" + DIGEST], + "FROM python:3.14-slim AS b": ["python:3.14-slim"], + "image: busybox:1.37": ["busybox:1.37"], + "x = 'registry.ollama.ai/library/qwen3:4b-q4_K_M'": ["registry.ollama.ai/library/qwen3:4b-q4_K_M"], + "pip install vllm==0.24.0 torch==2.9.0+cpu": ["vllm==0.24.0", "torch==2.9.0+cpu"], + "REV = 'a1b2c3d4e5f60718293a4b5c6d7e8f9012345678'": ["a1b2c3d4e5f60718293a4b5c6d7e8f9012345678"], + "KUBECTL_VERSION=v1.37.2 # newest": ["KUBECTL_VERSION=v1.37.2"], + "B1_TAG=v0.28.0@" + DIGEST: ["B1_TAG=v0.28.0@" + DIGEST, "v0.28.0@" + DIGEST], + "manifest " + DIGEST: [DIGEST], + # not pins: comments, awk, sysfs, command substitutions, plain variables + "# image: foo/bar:1.0": [], + "awk 'NR==2'": [], + 'z = "class/powercap/intel-rapl:0"': [], + "RAPL_IMAGE=$(sed -n 's/x//p' f)": [], + 'K3D_GPU_IMAGE="${IMAGE}" K3D_GPU_VENDORS=none': [], + "IMAGE=${1:?usage: probe.sh IMAGE}": [], + '-v "${OUT}/x.yaml:/app/config.yaml:ro"': [], + } + for line, want in cases.items(): + with self.subTest(line=line): + self.assertEqual(pc.pin_tokens(line), want) + + def test_overridable_default_is_the_same_pin(self): + ref = "cryptoandcoffee/k3d-gpu:0.1.63@" + DIGEST + self.assertEqual(pc.pin_tokens("K3D_GPU_IMAGE=" + ref + " \\")[0], + pc.pin_tokens(" K3D_GPU_IMAGE=${H57D_IMAGE:-" + ref + "} \\")[0]) + + def test_prose_holds_none(self): + self.assertEqual(pc.text_tokens("bench/x/README.md", "image: foo/bar:1"), []) + + +class Cli(unittest.TestCase): + def setUp(self): + self.d = tempfile.TemporaryDirectory() + write(self.d.name, {"EXPERIMENTS.md": LEDGER, "pins/classes": CLASSES, + "bench/x/run.sh": "OLD=foo/bar:1@sha256:aaaa\n", + "bench/x/old.sh": "echo\n", "bench/done/run.sh": "echo\n", + "bench/new/run.sh": "echo\n", "bench/tool.py": "x = 1\n", + "integrations/eat/v.yaml": "tag: 1\n"}) + + def tearDown(self): + self.d.cleanup() + + def test_class_and_effective(self): + self.assertEqual(cli("class", "bench/x/run.sh", root=self.d.name), (0, "ledger\n")) + self.assertEqual(cli("effective", "bench/x/run.sh", root=self.d.name), (0, "arm\n")) + self.assertEqual(cli("effective", "./bench/done/run.sh", root=self.d.name), (0, "historical\n")) + + def test_excludes(self): + rc, out = cli("excludes", root=self.d.name) + self.assertEqual((rc, out.split()), (0, ["bench/x/old.sh", "bench/x/**", "bench/done/**", "bench/new/**"])) + + def test_coverage(self): + rc, out = cli("coverage", root=self.d.name) + self.assertEqual(rc, 1, out) # H7 is not in the table + self.assertIn("bench/new/** names H7", out) + write(self.d.name, {"pins/classes": CLASSES.replace("ledger H7 amend=H7", "ledger H1 amend=H7")}) + self.assertEqual(cli("coverage", root=self.d.name)[0], 0) + write(self.d.name, {"bench/loose/run.sh": "IMG=ghcr.io/foo/bar:1.2@" + DIGEST + "\n"}) + rc, out = cli("coverage", root=self.d.name) + self.assertEqual(rc, 1) + self.assertIn("bench/loose/run.sh: holds pin tokens", out) + write(self.d.name, {"bench/loose/run.sh": "echo\n", "bench/x/run.sh": "OLD=foo/bar:2\n"}) + rc, out = cli("coverage", root=self.d.name) + self.assertEqual(rc, 1) + self.assertIn("historical-ref 'OLD=foo/bar:1@sha256:aaaa' is missing from bench/x/run.sh", out) + + def test_usage(self): + self.assertEqual(cli("class", root=self.d.name)[0], 2) + + +class RealRepo(unittest.TestCase): + """The committed pins/classes against the committed ledger.""" + + @classmethod + def setUpClass(cls): + cls.lines, cls.refs = pc.load(str(ROOT / "pins" / "classes")) + cls.ledger = pc.read_ledger(ROOT) + + def eff(self, path): + return pc.effective(self.lines, self.ledger, path, pc.read_text(str(ROOT), path))[0] + + def test_coverage_passes(self): + rc, out = cli("coverage") + self.assertEqual(rc, 0, out) + + def test_every_historical_ref_present(self): + self.assertTrue(self.refs) + for r in self.refs: + with self.subTest(ref=r.ref): + self.assertIn(r.ref, (ROOT / r.glob).read_text()) + + def test_finished_records_are_historical(self): + for path in ("console/matrix.v1.json", "oracle/seed/build.py"): + with self.subTest(path=path): + self.assertEqual(cli("effective", path)[1].strip(), "historical") + + # A runner or record and a row its ledger line must list. The class each resolves to + # comes from today's ledger, so a verdict landing (or a row re-opened) moves it with + # no change here: an arm while a listed row is PENDING, historical once none is. + LEDGER_PATHS = { + "bench/amx-autoquant/probe.sh": "H27", "bench/apb/arm.sh": "H8", "bench/apb/vllm-cuda.yaml": "H8", + "bench/apb/h7.sh": "H7", "bench/dra_round.sh": "H16", "bench/noisy.sh": "H26", + "bench/rdt-isolate/run.sh": "H26", "bench/thermal-resource/probe.sh": "H23", + "bench/prefill-guard/run.sh": "H5", "bench/h54gpu/h54b.sh": "H54b", "bench/h54/diag/b1g-stall.sh": "H54a", + "bench/eatflow/bfcl-constraints.txt": "H40", "bench/eatflow/coding-constraints.txt": "H40", + "bench/eatflow/results/2026-10-17/A1/commit.txt": "H40", "bench/h57d/results/2026-10-21/verdict.json": "H57da", + "bench/h54/h54.py": "H54q", "bench/h57d/h57d.py": "H57dp", "bench/wildct/hold.sh": "H18w", + "bench/eatflow/live.sh": "H53", "bench/eatflow/crescent/setup.sh": "H53", "bench/eatflow/agent.lock": "H53", + "bench/flashnext/h60.sh": "H60", "bench/admission/run.sh": "H55", "bench/h59/h59.py": "H59", + "bench/apb/h7x.sh": "H7x", "bench/xhost/hold.sh": "H7x", "bench/load/load.py": "H18w", + "bench/zero-copy-models/probe.sh": "H28w", "bench/hetero-spec/h34.sh": "H34w", + } + + def test_ledger_paths_follow_the_ledger(self): + for path, row in self.LEDGER_PATHS.items(): + with self.subTest(path=path): + self.assertTrue((ROOT / path).is_file(), path) + ln = pc.match(self.lines, path) + self.assertEqual(ln.kind, "ledger") + self.assertIn(row, ln.rows) + want = "arm" if "PENDING" in [self.ledger[r] for r in ln.rows] else "historical" + self.assertEqual(cli("effective", path)[1].strip(), want) + + # A runner one row wrote and another row's runner runs or imports: (path, the row that + # reuses it, the reusing file, the text in it that names the path). The path's line + # must list the reusing row, or the path turns historical when its own rows finish + # while the reusing row is still PENDING, and that row's amendments are refused. + SHARED_RUNNERS = ( + ("bench/h57d/hold.sh", "H57wa", "bench/wildct/row.sh", "bench/h57d/hold.sh"), + ("bench/h57d/convert.sh", "H57wm", "bench/wildct/row.sh", "bench/h57d/convert.sh"), + ("bench/h57d/h57d.py", "H59", "bench/h59/h59.py", "import h57d"), + ("bench/h54/h54.py", "H59", "bench/h59/h59.py", "import h54"), + ("bench/h54/h54.py", "H60", "bench/flashnext/h60.sh", '"${H54}/h54.py" void'), + ("bench/h54/gsm8k-test-0-199.jsonl", "H60", "bench/flashnext/h60.sh", "gsm8k-test-0-199.jsonl"), + ("bench/prefill-guard/cpufreq.sh", "H55", "bench/admission/run.sh", "bench/prefill-guard/cpufreq.sh"), + ("bench/prefill-guard/prompt.txt", "H55", "bench/admission/run.sh", "bench/prefill-guard/prompt.txt"), + ("bench/eatflow/crescent/serving.py", "H7x", "bench/xhost/hold.sh", "bench/eatflow/crescent/serving.py"), + ("bench/xhost/fetch_models.py", "H57wb", "bench/wildct/setup-models.sh", "bench/xhost/fetch_models.py"), + ("bench/load/load.py", "H53", "bench/eatflow/crescent/hold.sh", "load/load.py"), + ("bench/load/load.py", "H7x", "bench/xhost/hold.sh", "bench/load/load.py"), + ("bench/load/load.py", "H59w", "bench/wildct/hold.sh", "load.py"), + ) + + def test_shared_runners_list_the_rows_that_reuse_them(self): + for path, row, user, needle in self.SHARED_RUNNERS: + with self.subTest(path=path, row=row): + self.assertIn(needle, (ROOT / user).read_text(), "%s no longer names %s" % (user, path)) + self.assertIn(row, pc.match(self.lines, user).rows) + ln = pc.match(self.lines, path) + self.assertEqual(ln.kind, "ledger") + self.assertIn(row, ln.rows) + + def test_a_reused_runner_stays_an_arm_while_its_reuser_is_pending(self): + # The H57d runner's own rows have verdicts; wildct's H57w rows still run it. + for path, row, _, _ in self.SHARED_RUNNERS: + if self.ledger.get(row) == "PENDING": + with self.subTest(path=path, row=row): + self.assertEqual(self.eff(path), "arm") + # A finished run's records inside that runner tree stay historical. + for path in ("bench/h57d/results/2026-10-21/verdict.json", "bench/h57d/results/2026-10-29/verdict.json"): + with self.subTest(path=path): + self.assertTrue((ROOT / path).is_file(), path) + self.assertEqual(self.eff(path), "historical") + + def test_a_verdict_landing_flips_a_real_line_with_no_reclassification(self): + # The real pins/classes against a copy of today's ledger: a line with a PENDING row + # resolves to arm, and to historical once that row's verdict lands. + ln = next((ln for ln in self.lines if ln.kind == "ledger" and "PENDING" in + [self.ledger.get(r) for r in ln.rows]), None) + if ln is None: + self.skipTest("no ledger line has a PENDING row today") + self.assertEqual(pc.resolve(ln, self.ledger), "arm") + landed = dict(self.ledger, **{r: "INCONCLUSIVE" for r in ln.rows if self.ledger.get(r) == "PENDING"}) + self.assertEqual(pc.resolve(ln, landed), "historical") + + def test_shipped_bench_files_and_eat_are_ours_or_eat_stock(self): + for path in ("bench/vs.py", "bench/llm_load.py", "bench/energy.sh", "bench/agent_trace.py", + "bench/agent_trace_gen.py", "bench/apb/apb.py", "bench/apb/capacity.py", "bench/competitors.lock", + "oracle/bundle.py", "scripts/k3d-gpu", "share/agent-chat-ui.yml"): + with self.subTest(path=path): + self.assertEqual(self.eff(path), "ours") + for path in ("integrations/eat/vllm-values.yaml", "examples/kuberay/raycluster-hybrid.yaml", + "examples/agents/litellm-gateway/agent.py", "contrib/eat/hybrid-values.yaml", + "integrations/eas/pins.json"): + with self.subTest(path=path): + self.assertEqual(self.eff(path), "eat-stock") + + def test_every_ledger_row_is_in_the_table(self): + for ln in self.lines: + for r in ln.rows: + with self.subTest(glob=ln.glob, row=r): + self.assertIn(r, self.ledger) + + +if __name__ == "__main__": + os.chdir(ROOT) + unittest.main(verbosity=1) diff --git a/tests/pin_guard_test.sh b/tests/pin_guard_test.sh new file mode 100755 index 00000000..da4b6679 --- /dev/null +++ b/tests/pin_guard_test.sh @@ -0,0 +1,265 @@ +#!/usr/bin/env bash +# hack/pin-guard against throwaway git repos. Each case branches off one base commit +# (a small ledger, a pins/classes file and a few runners), makes one change, commits +# it and runs the guard with --base main. Commit dates are fixed (the base is +# 2026-10-29), so the amendment date rule (no earlier than the base's date minus one +# day) is deterministic. +# +# Usage: tests/pin_guard_test.sh + +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +GUARD=${GUARD:-${ROOT}/hack/pin-guard} +T=$(mktemp -d "${TMPDIR:-/tmp}/pin_guard_test.XXXXXX") +trap 'rm -rf "$T"' EXIT +PASS=0 FAILS=0 +ok() { PASS=$((PASS + 1)); echo "ok $1"; } +fail() { FAILS=$((FAILS + 1)); echo "FAIL $1"; } + +export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 +export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t +export GIT_AUTHOR_DATE=2026-10-29T12:00:00-07:00 GIT_COMMITTER_DATE=2026-10-29T12:00:00-07:00 +unset GITHUB_ACTIONS GITHUB_HEAD_REF GITHUB_REF_NAME + +D1=sha256:1111111111111111111111111111111111111111111111111111111111111111 +D2=sha256:2222222222222222222222222222222222222222222222222222222222222222 +OLD_REF="foo/old:1.0@${D1}" + +R="$T/repo" +git init -q -b main "$R" +mkdir -p "$R/bench/h54/results" "$R/bench/wildct" "$R/bench/old" "$R/bench/refuted" "$R/bench/reuse" "$R/pins" "$R/scripts" +cat > "$R/EXPERIMENTS.md" <<'EOF' +# Experiments + +## Hypotheses + +| # | Hypothesis | Prediction | Verdict | +|---|------------|------------|---------| +| H54a | a | p | INCONCLUSIVE | +| H54q | q | p | PENDING | +| H18w | w | p | PENDING | +| H52b | b | p | REFUTED | +| H60 | f | p | PENDING | + +## Runs + +### H54a run (Xeon 6741P, 2026-10-21) + +done +EOF +cat > "$R/pins/classes" <<EOF +bench/old/** historical a finished run +bench/h54/** ledger H54a,H54q amend=H54 the H54 runner +bench/wildct/** ledger H18w amend=Wildct the wildct runner +bench/refuted/** ledger H52b amend=Crescent a finished row +bench/reuse/** ledger H52b,H99 amend=Crescent a finished row's runner a new row reuses +bench/tool.py ours +historical-ref bench/h54/arm.sh ${OLD_REF} +EOF +printf '#!/bin/sh\nIMG=foo/bar:1.0@%s\nOLD=%s\necho run\n' "$D1" "$OLD_REF" > "$R/bench/h54/arm.sh" +printf 'image: foo/res:1.0@%s\n' "$D1" > "$R/bench/h54/results/r.txt" +printf '#!/bin/sh\nRAPL_IMAGE=python:3.13-slim@%s\n' "$D1" > "$R/bench/wildct/hold.sh" +printf '#!/bin/sh\nIMG=foo/old:1.0@%s\n' "$D1" > "$R/bench/old/run.sh" +printf 'echo\n' > "$R/bench/old/two words.sh" +printf '#!/bin/sh\nIMG=foo/ref:1.0@%s\necho a\n' "$D1" > "$R/bench/refuted/run.sh" +printf '#!/bin/sh\nIMG=foo/reuse:1.0@%s\n' "$D1" > "$R/bench/reuse/run.sh" +printf 'IMG = "foo/tool:1.0@%s"\n' "$D1" > "$R/bench/tool.py" +printf '#!/bin/sh\nIMG=foo/k:1.0@%s\n' "$D1" > "$R/scripts/k" +mkdir -p "$R/.github/workflows"; echo "name: CI" > "$R/.github/workflows/ci.yml" +git -C "$R" add -A && git -C "$R" commit -qm base + +# case NAME WANT_RC [GUARD ARGS...]: run the guard on branch NAME (made by the caller). +guard() { (cd "$R" && "$GUARD" --base main "$@") > "$T/out" 2>&1; echo $?; } +branch() { git -C "$R" switch -q -C "$1" main; } +commit() { git -C "$R" add -A && git -C "$R" commit -qm "$1"; } +amend() { # append a heading line to EXPERIMENTS.md + printf '\n%s\n\nbody\n' "$1" >> "$R/EXPERIMENTS.md" +} +bump() { sed -i "s#$1#$2#" "$R/$3"; } +expect() { # expect DESC WANT_RC [GREP] [GUARD ARGS...] + local desc=$1 want=$2 pat=${3:-} rc + shift 3 + rc=$(guard "$@") + if [ "$rc" = "$want" ] && { [ -z "$pat" ] || grep -qE -- "$pat" "$T/out"; }; then + ok "$desc" + else + fail "$desc (rc ${rc}, want ${want}${pat:+, /${pat}/})"; sed 's/^/ /' "$T/out" + fi +} + +branch hist-edit; bump "foo/old:1.0" "foo/old:2.0" bench/old/run.sh; commit x +expect "a historical edit fails" 1 "bench/old/run.sh: historical record modified" + +branch hist-del; git -C "$R" rm -q bench/old/run.sh; commit x +expect "a historical deletion fails" 1 "historical record deleted" + +branch hist-mv; git -C "$R" mv bench/old/run.sh bench/old/run2.sh; commit x +expect "a historical rename fails" 1 "historical record renamed" + +branch hist-space; echo "echo again" >> "$R/bench/old/two words.sh"; commit x +expect "a historical file whose name has a space is still guarded" 1 "bench/old/two words.sh: historical record modified" + +branch hist-ref; bump "OLD=foo/old:1.0" "OLD=foo/old:2.0" bench/h54/arm.sh +amend "### H54 amendment Q9: the old arm (2026-10-29)"; commit x +expect "a historical-ref change in a mixed file fails (even with an amendment)" 1 "historical-ref 'foo/old:1.0@" + +branch ref-new; printf 'NEW=%s\n' "$OLD_REF" >> "$R/bench/tool.py"; commit x +expect "a historical-ref string in another file is not counted" 0 "" + +branch arm-none; bump "foo/bar:1.0@${D1}" "foo/bar:1.1@${D2}" bench/h54/arm.sh; commit x +expect "an arm pin change without an amendment fails" 1 "bench/h54/arm.sh: arm pin change .* for PENDING H54q with no amendment" + +branch arm-row; bump "foo/bar:1.0@${D1}" "foo/bar:1.1@${D2}" bench/h54/arm.sh +amend "### H54 amendment Q9: foo/bar 1.1 for every arm (2026-10-29)"; commit x +expect "an arm pin change with 'H54 amendment Q9: ... (today)' passes" 0 "^ok" + +branch arm-row-id; bump "foo/bar:1.0@${D1}" "foo/bar:1.1@${D2}" bench/h54/arm.sh +amend "### H54q amendment Q9: foo/bar 1.1 (2026-10-28)"; commit x +expect "an amendment naming the PENDING row, dated the day before the base, passes" 0 "^ok" + +branch arm-form; bump "foo/bar:1.0@${D1}" "foo/bar:1.1@${D2}" bench/h54/arm.sh +amend "### H54 Amendment Q9 (foo/bar 1.1, 2026-10-29)"; commit x +expect "a heading not in the '<names> amendment <ID>: <title> (date)' form is no amendment" 1 "no amendment" + +branch arm-host; bump "foo/bar:1.0@${D1}" "foo/bar:1.1@${D2}" bench/h54/arm.sh +amend "### H54 Amendment Q9: foo/bar 1.1 (Xeon 6741P, 2026-10-29)"; commit x +expect "an amendment whose date follows a host in its parentheses, 'Amendment' capitalised, passes" 0 "^ok" + +branch arm-alias; bump "python:3.13-slim@${D1}" "python:3.14-slim@${D2}" bench/wildct/hold.sh +amend "### Wildct amendment P2: python 3.14 for the RAPL reader (2026-10-29)"; commit x +expect "an alias heading (Wildct amendment P2) passes" 0 "^ok" + +branch arm-other; bump "foo/bar:1.0@${D1}" "foo/bar:1.1@${D2}" bench/h54/arm.sh +amend "### H60 amendment V9: another row (2026-10-29)"; commit x +expect "an amendment naming a different row fails" 1 "arm pin change .* no amendment" + +branch arm-stale; bump "foo/bar:1.0@${D1}" "foo/bar:1.1@${D2}" bench/h54/arm.sh +amend "### H54 amendment Q9: foo/bar 1.1 (2026-10-27)"; commit x +expect "a stale-dated amendment fails" 1 "is dated before 2026-10-28" + +branch arm-refuted; bump "foo/ref:1.0@${D1}" "foo/ref:1.1@${D2}" bench/refuted/run.sh +amend "### Crescent amendment E9: a newer ref (2026-10-29)"; commit x +expect "an arm change for a row now REFUTED (now historical) fails" 1 "bench/refuted/run.sh: historical record modified" + +branch arm-nonpin; bump "echo run" "echo run twice" bench/h54/arm.sh; commit x +expect "a non-pin runner edit in an arm path passes" 0 "^ok" + +branch arm-results; bump "foo/res:1.0@${D1}" "foo/res:1.1@${D2}" bench/h54/results/r.txt; commit x +expect "a pin change under results/ passes" 0 "^ok" + +branch arm-new; printf 'IMG=foo/new:1.0@%s\n' "$D1" > "$R/bench/h54/new.sh"; commit x +expect "a new file in an arm path passes (it moves no pin a row has)" 0 "^ok" + +branch started; sed -i 's/^### H54a run/### H54q run/' "$R/EXPERIMENTS.md"; commit started +git -C "$R" switch -q -c started-pr +bump "foo/bar:1.0@${D1}" "foo/bar:1.1@${D2}" bench/h54/arm.sh +amend "### H54 amendment Q9: foo/bar 1.1 (2026-10-29)"; commit x +rc=$(cd "$R" && "$GUARD" --base started > "$T/out" 2>&1; echo $?) +if [ "$rc" = 0 ] && grep -q '^::warning::bench/h54/arm.sh: H54q has a run heading' "$T/out"; then + ok "an amendment to a row that has started warns but passes" +else + fail "an amendment to a row that has started warns but passes (rc ${rc})"; sed 's/^/ /' "$T/out" +fi + +branch ours; bump "foo/tool:1.0" "foo/tool:2.0" bench/tool.py; bump "foo/k:1.0" "foo/k:2.0" scripts/k; commit x +expect "an ours edit passes" 0 "^ok" + +branch landing; bump "| H52b | b | p | REFUTED |" "| H52b | b | p | INCONCLUSIVE |" EXPERIMENTS.md +bump "echo a" "echo b" bench/refuted/run.sh; commit x +expect "a verdict landing that edits its finished row's runner (no pin) passes" 0 "^ok" + +branch prereg; printf '| H99 | n | p | PENDING |\n' > "$T/row" +sed -i "/^| H60 /r $T/row" "$R/EXPERIMENTS.md" +amend "### H99 pre-registration: a new row on the old runner (2026-10-29)" +bump "foo/reuse:1.0" "foo/reuse:2.0" bench/reuse/run.sh; commit x +expect "a pre-registration that reuses a finished row's runner sets its pins" 0 "^ok" + +# A new row reusing a finished line's runner adds itself to that line in the same diff. +newrow() { printf '| %s | n | p | PENDING |\n' "$1" > "$T/row"; sed -i "/^| H60 /r $T/row" "$R/EXPERIMENTS.md"; } +branch prereg-line; newrow H98; amend "### H98 pre-registration: H52b's runner again (2026-10-29)" +sed -i 's|^bench/refuted/\*\* .*|bench/refuted/** ledger H52b,H98 amend=Crescent a finished row, reused by H98|' "$R/pins/classes" +bump "foo/ref:1.0" "foo/ref:1.1" bench/refuted/run.sh; commit x +expect "a pre-registration that adds its row to a finished line in the same diff sets its pins" 0 "^ok" + +branch prereg-noline; newrow H98; amend "### H98 pre-registration: H52b's runner again (2026-10-29)" +bump "foo/ref:1.0" "foo/ref:1.1" bench/refuted/run.sh; commit x +expect "a pre-registration whose row the line does not list leaves the record historical" 1 "bench/refuted/run.sh: historical record modified" + +branch prereg-nohead; newrow H98 +sed -i 's|^bench/refuted/\*\* .*|bench/refuted/** ledger H52b,H98 amend=Crescent a finished row, reused by H98|' "$R/pins/classes" +bump "foo/ref:1.0" "foo/ref:1.1" bench/refuted/run.sh; commit x +expect "a new row on the line with no pre-registration heading leaves the record historical" 1 "bench/refuted/run.sh: historical record modified" + +# A PENDING row's amendment that moves its arm onto a finished line's runner joins the line. +joined() { # joined BRANCH [AMENDMENT HEADING] + branch "$1" + sed -i 's|^bench/refuted/\*\* .*|bench/refuted/** ledger H52b,H60 amend=Crescent a finished row, reused by H60|' "$R/pins/classes" + [ -z "${2:-}" ] || amend "$2" + bump "foo/ref:1.0" "foo/ref:1.1" bench/refuted/run.sh; commit x +} +joined joined-amend "### H60 amendment V9: H52b's runner for the second arm (2026-10-29)" +expect "a PENDING row's amendment that adds it to a finished line in the same diff sets its pins" 0 "^ok" +joined joined-none +expect "a PENDING row added to a finished line with no amendment leaves the record historical" 1 "bench/refuted/run.sh: historical record modified" +joined joined-stale "### H60 amendment V9: H52b's runner for the second arm (2026-10-20)" +expect "a stale amendment that adds a PENDING row to a finished line fails" 1 "is dated before 2026-10-28" + +branch reclass; sed -i 's|^bench/old/\*\* .*|bench/old/** ours now|' "$R/pins/classes" +bump "foo/old:1.0" "foo/old:2.0" bench/old/run.sh; commit x +expect "reclassifying a historical path in the same diff does not free it" 1 "bench/old/run.sh: historical record modified" + +branch loose; printf '#!/bin/sh\nIMG=ghcr.io/foo/loose:1.0@%s\n' "$D1" > "$R/bench/loose.sh"; commit x +expect "an unclassified bench pin fails" 1 "bench/loose.sh: holds pin tokens .* classify it in pins/classes" + +branch loose-nopin; printf '#!/bin/sh\necho hi\n' > "$R/bench/loose.sh"; commit x +expect "an unclassified bench file without pins passes" 0 "^ok" + +branch bot-gh; echo "on: push" >> "$R/.github/workflows/ci.yml"; commit x +expect "--autobump touching .github fails" 1 "\.github/workflows/ci.yml: an autobump branch never touches .github/" --autobump --branch autobump/k3s + +branch bot-exp; amend "### H54 amendment Q9: x (2026-10-29)"; commit x +expect "--autobump touching EXPERIMENTS.md fails" 1 "EXPERIMENTS.md: only an autobump/amend-\* branch" --autobump --branch autobump/k3s +expect "--autobump touching EXPERIMENTS.md passes on autobump/amend-*" 0 "^ok" --autobump --branch autobump/amend-H54q + +branch bot-amend-gh; echo "on: push" >> "$R/.github/workflows/ci.yml"; amend "### H54 amendment Q9: x (2026-10-29)"; commit x +expect "--autobump touching .github fails on autobump/amend-* too" 1 "never touches .github/" --autobump --branch autobump/amend-H54q + +branch bot-classes; echo "bench/x/** ours" >> "$R/pins/classes"; commit x +expect "--autobump touching pins/classes fails" 1 "never reclassifies pins" --autobump --branch autobump/k3s +expect "a human branch may touch .github and pins/classes" 0 "^ok" + +# The ledger's real headings parse to the names the guard matches rows and aliases by. +if out=$(python3 - "${ROOT}/EXPERIMENTS.md" "${GUARD}" 2>&1 <<'PY' +import importlib.machinery, importlib.util, sys +text = open(sys.argv[1]).read().splitlines() +loader = importlib.machinery.SourceFileLoader("pin_guard", sys.argv[2]) +g = importlib.util.module_from_spec(importlib.util.spec_from_loader("pin_guard", loader)) +loader.exec_module(g) +want = { + "### Crescent amendment E2:": {"crescent"}, + "### H54 amendment Q1:": {"h54"}, + "### Wildct amendment P1:": {"wildct"}, + "### H57dp amendment R2:": {"h57dp"}, + "### H60 amendment V1:": {"h60"}, + "### H47 and H49 amendment P1:": {"h47", "h49"}, + "### H30-H32 amendment A1:": {"h30", "h31", "h32"}, + "### H54b-H54c amendment G1:": {"h54b", "h54c"}, + "### Cross-host amendment K1:": {"cross-host"}, +} +for prefix, names in want.items(): + line = next(l for l in text if l.startswith(prefix)) + m = g.AMEND_RE.match(line) + assert m and g.DATE_RE.search(line), line + assert names <= g.names_of(m.group("names")), (line, g.names_of(m.group("names"))) +run = next(l for l in text if l.startswith("### H40-H48 run")) +assert "h43" in g.names_of(g.RUN_RE.match(run).group("names")), run +PY +); then ok "the ledger's real amendment and run headings parse to their rows and aliases" +else fail "real headings"; sed 's/^/ /' <<< "${out}"; fi + +branch usage; rc=$(cd "$R" && "$GUARD" --base no-such-ref > "$T/out" 2>&1; echo $?) +if [ "$rc" = 2 ]; then ok "an unknown base is a usage error (exit 2)"; else fail "an unknown base exits ${rc}, want 2"; fi + +echo "${PASS} passed, ${FAILS} failed" +[ "${FAILS}" -eq 0 ] diff --git a/tests/release_tags_test.sh b/tests/release_tags_test.sh new file mode 100755 index 00000000..f2b4967d --- /dev/null +++ b/tests/release_tags_test.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# hack/release-tags against a local bare origin over file:// (no network): the +# release build pushes the versioned tags of every release, and copies the +# floating tags (latest, ubuntu26.04, <k3s>-ubuntu26.04 and the 24.04 ones) only +# for the highest aur-v* tag on the remote at push time. +# +# Usage: tests/release_tags_test.sh + +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +RT=${RT:-${ROOT}/hack/release-tags} +T=$(mktemp -d "${TMPDIR:-/tmp}/release_tags_test.XXXXXX") +trap 'rm -rf "$T"' EXIT +PASS=0 FAILS=0 +ok() { PASS=$((PASS + 1)); echo "ok $1"; } +fail() { FAILS=$((FAILS + 1)); echo "FAIL $1"; } +export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 +export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t +unset RELEASE_REPOS + +# origin.git with tags aur-v0.1.9 and aur-v0.1.10 (sort -V, not sort), plus an +# annotated tag (its peeled ^{} line must not count) and a non-release tag. +git init -q --bare -b main "$T/origin.git" +git init -q -b main "$T/w" +( cd "$T/w" && git commit -q --allow-empty -m one && git tag aur-v0.1.9 \ + && git commit -q --allow-empty -m two && git tag aur-v0.1.10 \ + && git tag -a -m note aur-v0.1.8 HEAD~1 && git tag v9.9.9 \ + && git push -q "$T/origin.git" main --tags ) +K3S=v1.37.1-k3s1 +run() { ( cd "$T/w" && REMOTE="file://$T/origin.git" bash "$RT" "$@" ) > "$T/out" 2> "$T/err"; } + +want_versioned="26.04 versioned cryptoandcoffee/k3d-gpu:0.1.10 +26.04 versioned cryptoandcoffee/k3d-gpu:0.1.10-ubuntu26.04 +24.04 versioned cryptoandcoffee/k3d-gpu:0.1.10-ubuntu24.04 +26.04 versioned ghcr.io/88plug/k3d-gpu:0.1.10 +26.04 versioned ghcr.io/88plug/k3d-gpu:0.1.10-ubuntu26.04 +24.04 versioned ghcr.io/88plug/k3d-gpu:0.1.10-ubuntu24.04" +want_floating="26.04 floating cryptoandcoffee/k3d-gpu:latest cryptoandcoffee/k3d-gpu:0.1.10 +26.04 floating cryptoandcoffee/k3d-gpu:ubuntu26.04 cryptoandcoffee/k3d-gpu:0.1.10 +26.04 floating cryptoandcoffee/k3d-gpu:${K3S}-ubuntu26.04 cryptoandcoffee/k3d-gpu:0.1.10 +24.04 floating cryptoandcoffee/k3d-gpu:ubuntu24.04 cryptoandcoffee/k3d-gpu:0.1.10-ubuntu24.04 +24.04 floating cryptoandcoffee/k3d-gpu:${K3S}-ubuntu24.04 cryptoandcoffee/k3d-gpu:0.1.10-ubuntu24.04 +26.04 floating ghcr.io/88plug/k3d-gpu:latest ghcr.io/88plug/k3d-gpu:0.1.10 +26.04 floating ghcr.io/88plug/k3d-gpu:ubuntu26.04 ghcr.io/88plug/k3d-gpu:0.1.10 +26.04 floating ghcr.io/88plug/k3d-gpu:${K3S}-ubuntu26.04 ghcr.io/88plug/k3d-gpu:0.1.10 +24.04 floating ghcr.io/88plug/k3d-gpu:ubuntu24.04 ghcr.io/88plug/k3d-gpu:0.1.10-ubuntu24.04 +24.04 floating ghcr.io/88plug/k3d-gpu:${K3S}-ubuntu24.04 ghcr.io/88plug/k3d-gpu:0.1.10-ubuntu24.04" + +# 1. The highest release: versioned and floating tags. +run 0.1.10 "$K3S"; rc=$? +if [ "$rc" -eq 0 ] && diff <(sort "$T/out") <(printf '%s\n%s\n' "$want_versioned" "$want_floating" | sort) >/dev/null; then + ok "the highest aur-v* tag gets its versioned and floating tags" +else + fail "highest: rc=$rc $(diff <(sort "$T/out") <(printf '%s\n%s\n' "$want_versioned" "$want_floating" | sort) | head -5 | tr '\n' ' ') $(cat "$T/err")" +fi + +# 2. An older release (a newer tag exists on the remote): versioned tags only. +run 0.1.9 "$K3S"; rc=$? +if [ "$rc" -eq 0 ] && diff "$T/out" <(printf '%s\n' "${want_versioned//0.1.10/0.1.9}") >/dev/null; then + ok "an older release gets versioned tags only" +else + fail "older: rc=$rc out=$(tr '\n' ' ' < "$T/out")" +fi + +# 3. A version above every tag on the remote is not the highest tag: versioned only. +run 0.1.11 "$K3S"; rc=$? +if [ "$rc" -eq 0 ] && ! grep -q floating "$T/out" && [ "$(wc -l < "$T/out")" -eq 6 ]; then + ok "a version that is not a tag on the remote moves no floating tag" +else + fail "untagged: rc=$rc out=$(tr '\n' ' ' < "$T/out")" +fi + +# 4. RELEASE_REPOS picks the repositories. +( cd "$T/w" && RELEASE_REPOS="example.org/a/b" REMOTE="file://$T/origin.git" bash "$RT" 0.1.10 "$K3S" ) > "$T/out" 2>&1; rc=$? +if [ "$rc" -eq 0 ] && [ "$(grep -c . "$T/out")" -eq 8 ] && ! grep -qv ' example.org/a/b:' "$T/out"; then + ok "RELEASE_REPOS sets the repositories" +else + fail "RELEASE_REPOS: rc=$rc out=$(tr '\n' ' ' < "$T/out")" +fi + +# 5. The remote cannot be read: fail, print no tag (never fall back to floating). +( cd "$T/w" && REMOTE="file://$T/missing.git" bash "$RT" 0.1.10 "$K3S" ) > "$T/out" 2> "$T/err"; rc=$? +if [ "$rc" -ne 0 ] && [ ! -s "$T/out" ]; then + ok "an unreadable remote fails with no tag printed" +else + fail "unreadable remote: rc=$rc out=$(tr '\n' ' ' < "$T/out")" +fi + +# 6. Bad arguments exit 2. +run 0.1 "$K3S"; rc1=$? +run 0.1.10 "1.37.1"; rc2=$? +run 0.1.10; rc3=$? +if [ "$rc1" -eq 2 ] && [ "$rc2" -eq 2 ] && [ "$rc3" -eq 2 ]; then + ok "a bad version, k3s tag or argument count exits 2" +else + fail "bad args: rc=$rc1/$rc2/$rc3" +fi + +echo "${PASS} passed, ${FAILS} failed" +[ "${FAILS}" -eq 0 ] diff --git a/tests/remote/chat-ui.golden b/tests/remote/chat-ui.golden index b723acff..bd300fb3 100644 --- a/tests/remote/chat-ui.golden +++ b/tests/remote/chat-ui.golden @@ -64,7 +64,7 @@ spec: containers: - name: open-webui # ghcr.io/open-webui/open-webui:v0.12.2 - image: ghcr.io/open-webui/open-webui@sha256:10e6a78ba2d172493460a9e8a92c496aaa7a64dc4d26d622a2e95e0c736081c9 + image: ghcr.io/open-webui/open-webui:v0.12.2@sha256:10e6a78ba2d172493460a9e8a92c496aaa7a64dc4d26d622a2e95e0c736081c9 imagePullPolicy: IfNotPresent # The image runs as root and writes under /app/backend at runtime. securityContext: diff --git a/tests/remote_test.sh b/tests/remote_test.sh index e3f7d59e..2f3ce381 100755 --- a/tests/remote_test.sh +++ b/tests/remote_test.sh @@ -61,6 +61,8 @@ case "$*" in *"get secret k3d-gpu-llm -o jsonpath"*) ns=$2; [ -f "${STUB_STATE}/secret-key-${ns}" ] || exit 1; cat "${STUB_STATE}/secret-key-${ns}" ;; *"get sandboxtemplate"*) [ -f "${STUB_STATE}/pin" ] || exit 1; cat "${STUB_STATE}/pin" ;; + *"get deployment agent-sandbox-controller"*) [ ! -f "${STUB_STATE}/controller" ] || cat "${STUB_STATE}/controller" ;; + *"get crd "*) for c in sandboxtemplates sandboxwarmpools; do printf '%s.extensions.agents.x-k8s.io|v1beta1||\n' "${c}"; done ;; *"get secret open-webui-admin"*) exit 1 ;; *"delete secret"*) rm -f "${STUB_STATE}/secret-${2}" "${STUB_STATE}/secret-key-${2}" ;; esac @@ -202,6 +204,16 @@ has "sandbox up --remote: sandbox Secret copy" "$(cat "${S}/kubectl.log")" "stdi grep -n 'stdin-secret\|apply -f -' "${S}/kubectl.log" | grep -q 'stdin-secret' && \ [ "$(grep -n 'stdin-secret' "${S}/kubectl.log" | head -1 | cut -d: -f1)" -lt "$(grep -n '^kubectl apply -f -$' "${S}/kubectl.log" | tail -1 | cut -d: -f1)" ] \ && ok "sandbox up --remote: Secret before the profile" || fail "sandbox up --remote: Secret before the profile" +# --adopt next to another installer's controller: the same pinned profile, no controller manifest. +: > "${S}/kubectl.log"; : > "${S}/applied" +printf 'registry.k8s.io/agent-sandbox/agent-sandbox-controller:v0.5.0||' > "${S}/controller" +out=$(K3D_GPU_REMOTE_ADDRS=104.21.77.178,172.67.210.235 OPENAI_BASE_URL="https://llm.example.net/v1" OPENAI_API_KEY="${KEY}" \ + run eval 'remote_resolve() { printf "https://llm.example.net/v1\tzai-org/GLM-5.3\tOPENAI_BASE_URL\n"; }; sandbox_status() { :; }; SANDBOX_TIMEOUT=0; kubectl() { command kubectl "$@"; [ "$*" != "-n agent-sandbox get sandboxwarmpool python-ecores -o jsonpath={.status.readyReplicas}" ] || echo 1; }; cmd_sandbox up --adopt --remote') +rm -f "${S}/controller" +has "sandbox up --adopt --remote: adopts the other controller" "${out}" "adopting agent-sandbox-system/agent-sandbox-controller (registry.k8s.io/agent-sandbox/agent-sandbox-controller:v0.5.0)" +has "sandbox up --adopt --remote: pinned profile applied" "$(cat "${S}/applied")" "cidr: 104.21.77.178/32" +has "sandbox up --adopt --remote: sandbox Secret copy" "$(cat "${S}/kubectl.log")" "stdin-secret apply --server-side --force-conflicts --field-manager=k3d-gpu -f -" +lacks "sandbox up --adopt --remote: no controller manifest" "$(cat "${S}/kubectl.log")" "share/agent-sandbox.yml" out=$(run cmd_sandbox status --remote) has "sandbox: --remote only with up" "${out}" "--remote goes with 'sandbox up'" printf 'llm.example.net 104.21.77.178,172.67.210.235' > "${S}/pin" diff --git a/tests/share_kubeconform.sh b/tests/share_kubeconform.sh index 46b59bd3..26abc59c 100755 --- a/tests/share_kubeconform.sh +++ b/tests/share_kubeconform.sh @@ -16,8 +16,9 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" -# ghcr.io/yannh/kubeconform:v0.8.0 -KUBECONFORM_IMAGE="ghcr.io/yannh/kubeconform@sha256:faffaf43f95aa6425306e1ab8d6fcad72acb9049158f38e574c085ea1ec0f64e" +# The pinned kubeconform image, where the CI jobs read it too. +KUBECONFORM_IMAGE=$(sed -n 's/^KUBECONFORM_IMAGE=//p' "${ROOT}/hack/ci-tools.env") +: "${KUBECONFORM_IMAGE:?hack/ci-tools.env has no KUBECONFORM_IMAGE}" K8S_VERSION=$(sed -n 's/^ARG K3S_TAG="v\([0-9.]*\)-k3s.*/\1/p' "${ROOT}/Dockerfile") [ -n "${K8S_VERSION}" ] || { echo "no K3S_TAG version in Dockerfile" >&2; exit 1; } WORK=$(mktemp -d) diff --git a/tests/tune_test.sh b/tests/tune_test.sh index 6fb9f349..1d5e6ab1 100755 --- a/tests/tune_test.sh +++ b/tests/tune_test.sh @@ -91,6 +91,11 @@ a=$(fp "${h1}") b=$(fp "${h2}") c=$(fp "${h3}") d=$(fp "${h1}" K3D_GPU_AGENT_MOD [ "${a}" = "${b}" ] && ok "same hardware, same fingerprint" || bad "same hardware, same fingerprint" "${a} vs ${b}" [ "${a}" != "${c}" ] && ok "different E-core set, different fingerprint" || bad "E-core set ignored" [ "${a}" != "${d}" ] && ok "different model, different fingerprint" || bad "model ignored" +# The OVMS pin is written name:tag@sha256:...; the fingerprint names it by digest +# alone, as when the pin carried no tag, so saved profiles still apply. +in=$(run env K3D_GPU_SYSROOT="${h1}" "${K3D_GPU}" tune fingerprint | sed -n 's/^ agent_image=//p') +if [[ "${in}" =~ ^openvino/model_server@sha256:[0-9a-f]{64}$ ]]; then ok "fingerprint names the OVMS pin by digest alone" +else bad "fingerprint agent_image" "${in}"; fi echo "== profile application (what 'agent up' exports)" mkdir -p "${WORK}/profiles" diff --git a/tests/watchdog_test.sh b/tests/watchdog_test.sh new file mode 100755 index 00000000..bcc03ae9 --- /dev/null +++ b/tests/watchdog_test.sh @@ -0,0 +1,519 @@ +#!/usr/bin/env bash +# hack/watchdog against tests/ops/fake_gh.py (routes plus an issue store), a stub +# registry (tests/ops/stub_registry.py), a file:// AUR answer and a local git +# repository holding the aur-v* tags. No network. The real hack/ci_alarm files the +# alarms into the fake issue store. Run lists, jobs, the failed log and the +# workflow list are the real gh shapes (tests/ops/fixtures/, from the October red +# nights of build-and-push.yml); each case moves the clock (WATCHDOG_NOW) and the +# fixtures the fake gh answers with. +# +# Usage: tests/watchdog_test.sh + +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WATCHDOG=${WATCHDOG:-${ROOT}/hack/watchdog} +FIX="${ROOT}/tests/ops/fixtures" +T=$(mktemp -d "${TMPDIR:-/tmp}/watchdog_test.XXXXXX") +trap '[ -n "${KEEP:-}" ] || rm -rf "$T"' EXIT +PASS=0 FAILS=0 +ok() { PASS=$((PASS + 1)); echo "ok $1"; } +fail() { FAILS=$((FAILS + 1)); echo "FAIL $1"; } + +mkdir -p "$T/bin" +printf '#!/bin/sh\nexec python3 %q "$@"\n' "${ROOT}/tests/ops/fake_gh.py" > "$T/bin/gh" +chmod +x "$T/bin/gh" +export PATH="$T/bin:$PATH" GH_REPO=88plug/k3d-gpu +export WATCHDOG_IMAGES_LOCK="${ROOT}/tests/ops/stub_registry.py" +export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 +unset GITHUB_STEP_SUMMARY SIMULATE_RED ALARM_OWNER +HEAD_SHA=890d1d16790ed656ae5aa86a8319e578310b0f80 +PR_SHA=1bf784c727bc3c2a051d5022681b0fa51b7d7ccf + +# iso OFFSET: $NOW moved by OFFSET (a GNU date relative item, e.g. "-73 hours"). +iso() { date -u -d "$NOW ${1:-}" +%Y-%m-%dT%H:%M:%SZ; } +at() { # at ISO: set the clock for the watchdog, ci_alarm and the fake gh + NOW=$1 + export FAKE_GH_NOW=$NOW + WATCHDOG_NOW=$(date -u -d "$NOW" +%s); CI_ALARM_NOW=$WATCHDOG_NOW + export WATCHDOG_NOW CI_ALARM_NOW +} +# route REGEX FILE [RC] [STDERR]: answered before every earlier route. +route() { + { printf '%s\t%s\t%s' "$1" "$2" "${3:-0}"; [ -n "${4:-}" ] && printf '\t%s' "$4"; printf '\n' + cat "$C/routes.tsv" 2>/dev/null; } > "$C/routes.tmp" + mv "$C/routes.tmp" "$C/routes.tsv" +} +put() { cat > "$C/$1"; } # put FILE: write a fixture file from stdin +# tags "aur-vX@OFFSET ...": the case root's git history, one commit per tag. +tags() { + local spec t + rm -rf "$C/root/.git" + git -C "$C/root" init -q -b main + for spec in $1; do + t=${spec%%@*} + GIT_AUTHOR_DATE=$(iso "${spec#*@}") GIT_COMMITTER_DATE=$(iso "${spec#*@}") \ + git -C "$C/root" -c user.name=t -c user.email=t@t commit -q --allow-empty -m "release: $t" + git -C "$C/root" tag "$t" + done +} +registry() { printf '%s\n' "$@" > "$C/registry"; } +aur() { printf '{"resultcount":1,"results":[{"Name":"k3d-gpu","Version":"%s"}],"type":"multiinfo","version":5}\n' "$1" > "$C/aur.json"; } +runs() { # runs FILE: a ci.yml run list; each argument "event:status:conclusion[:sha[:createdAt]]" + local f=$1; shift + printf '%s\n' "$@" | jq -R 'split(":") | {databaseId: 4100, event: .[0], status: .[1], conclusion: .[2], + headSha: (.[3] // ""), createdAt: (.[4] // ""), url: "https://github.com/88plug/k3d-gpu/actions/runs/4100"}' \ + | jq -s . > "$C/$f" +} + +# setup NAME [ISO]: a healthy repository at ISO (default 2026-10-29T13:00:00Z): +# every scheduled workflow green, main's HEAD an hour old with green CI, release +# 0.1.79 complete everywhere, no bot PR, no open alarm. +setup() { + C=$T/$1 + export FAKE_GH=$C + mkdir -p "$C/root/.github" + cp -r "${ROOT}/.github/workflows" "$C/root/.github/" + at "${2:-2026-10-29T13:00:00Z}" + # The workflow list: the real one, plus every workflow file the repo has now + # (watchdog.yml registered an hour ago). + python3 - "$FIX/workflows.json" "$C/root/.github/workflows" "$(iso '-1 hour')" > "$C/workflows.json" <<'PY' +import json, os, re, sys +doc = json.load(open(sys.argv[1])) +have = {w["path"] for w in doc["workflows"]} +for f in sorted(os.listdir(sys.argv[2])): + p = ".github/workflows/" + f + if p not in have: + name = re.search(r"^name:\s*(.+)$", open(os.path.join(sys.argv[2], f)).read(), re.M).group(1).strip("'\"") + doc["workflows"].append({"id": 900000 + len(doc["workflows"]), "name": name, "path": p, "state": "active", + "created_at": sys.argv[3], "updated_at": sys.argv[3]}) +doc["total_count"] = len(doc["workflows"]) +print(json.dumps(doc)) +PY + jq --arg t "$(iso '-1 hour')" '[.[0] | .createdAt = $t | .startedAt = $t | .updatedAt = $t]' \ + "$FIX/build-and-push_schedule_runs.json" > "$C/bp_sched.json" + jq -n --arg t "$(iso '-13 minutes')" '[{databaseId: 4200, conclusion: "success", status: "completed", + createdAt: $t, url: "https://github.com/88plug/k3d-gpu/actions/runs/4200", headSha: "x", + workflowName: "Watchdog", event: "schedule"}]' > "$C/wd_sched.json" + # The scheduled workflows the watchdog finds besides those two (Canary, Bot sweep) + # ran green 13 minutes ago. + jq '.[0].workflowName = "Scheduled"' "$C/wd_sched.json" > "$C/other_sched.json" + jq -n --arg s "$HEAD_SHA" --arg t "$(iso '-1 hour')" \ + '{sha: $s, commit: {committer: {name: "88plug", date: $t}, message: "bench(wildct): smoke3"}}' > "$C/head.json" + runs ci_head.json "push:completed:success:$HEAD_SHA:$(iso '-58 minutes')" + runs ci_main.json "push:completed:success:$HEAD_SHA:$(iso '-58 minutes')" + echo '[]' > "$C/busy.json" + echo '[]' > "$C/prs.json" + jq -n '{assets: [{name: "k3d-gpu-0.1.79-1.noarch.rpm", state: "uploaded"}, + {name: "k3d-gpu_0.1.79-1_all.deb", state: "uploaded"}, + {name: "SHA256SUMS", state: "uploaded"}]}' > "$C/rel79.json" + for v in 75 77 78; do sed "s/0\.1\.79/0.1.$v/g" "$C/rel79.json" > "$C/rel$v.json"; done + cp "$FIX/run_39412124435_jobs.json" "$C/jobs.json" + cp "$FIX/run_39412124435_log_failed.txt" "$C/log.txt" + tags "aur-v0.1.78@-6hours aur-v0.1.79@-2hours" + registry docker.io/cryptoandcoffee/k3d-gpu:0.1.78 docker.io/cryptoandcoffee/k3d-gpu:0.1.79 \ + ghcr.io/88plug/k3d-gpu:0.1.78 ghcr.io/88plug/k3d-gpu:0.1.79 + aur 0.1.79-1 + export STUB_REGISTRY="$C/registry" WATCHDOG_AUR_URL="file://$C/aur.json" + rm -f "$C/routes.tsv" + route '^api repos/88plug/k3d-gpu/actions/workflows' workflows.json + route '^run list .*--workflow build-and-push\.yml .*--event schedule' bp_sched.json + route '^run list .*--workflow watchdog\.yml .*--event schedule' wd_sched.json + route '^run list .*--workflow (canary|bot-sweep)\.yml .*--event schedule' other_sched.json + route '^api repos/88plug/k3d-gpu/commits/main' head.json + route '^run list .*--workflow ci\.yml .*--commit ' ci_head.json + route '^run list .*--workflow ci\.yml .*--branch main' ci_main.json + route '^run list -R \S+ --limit \d+ --json workflowName' busy.json + route '^release view ' - 1 'release not found' + route '^release view aur-v0\.1\.79 ' rel79.json + route '^release view aur-v0\.1\.78 ' rel78.json + route '^pr list ' prs.json + route '^run view \d+ .*--json jobs' jobs.json + route '^run view \d+ .*--log-failed' log.txt +} +watch() { python3 "$WATCHDOG" --root "$C/root" "$@" > "$C/out" 2> "$C/err"; } +issue() { # issue KEY: the newest issue for the key, as JSON + jq -c --arg p "[ci-alarm] $1: " '[.[] | select(.title | startswith($p))] | max_by(.number) // empty' "$C/issues.json" 2>/dev/null +} +state() { local i; i=$(issue "$1"); if [ -z "$i" ]; then echo none; else jq -r .state <<< "$i"; fi; } +nopen() { jq '[.[] | select(.state == "OPEN")] | length' "$C/issues.json" 2>/dev/null || echo 0; } +dispatched() { grep -cxF "$1" "$C/dispatches.log" 2>/dev/null || true; } +ndispatch() { if [ -f "$C/dispatches.log" ]; then wc -l < "$C/dispatches.log"; else echo 0; fi; } +why() { echo "rc=$rc open=$(nopen) dispatches=$(ndispatch) err=$(tail -3 "$C/err" | tr '\n' ' ') keys=$(jq -r '[.[] | select(.state == "OPEN") | .title] | join(" | ")' "$C/issues.json" 2>/dev/null)"; } + +# 1. A healthy repository: no alarm, no dispatch, exit 0. +setup healthy +watch; rc=$? +if [ "$rc" -eq 0 ] && [ "$(nopen)" = 0 ] && [ "$(ndispatch)" = 0 ] && ! grep -q 'no route' "$C/err"; then + ok "healthy: no alarm, no dispatch" +else + fail "healthy: $(why)" +fi + +# 2. The October red nights of build-and-push.yml, replayed from the real run list. +# Night 1 (run 39412124435 red at 11:02, the watchdog at 11:47): red alarm and one +# re-dispatch; the hourly run after it adds neither. +setup october 2026-10-20T11:47:00Z +night() { # night ISO: the scheduled runs created up to ISO, newest first + at "$1" + jq --arg t "$1" '[.[] | select(.createdAt <= $t)]' "$FIX/build-and-push_schedule_runs.json" > "$C/bp_sched.json" + jq -n --arg s "$HEAD_SHA" --arg t "$(iso '-1 hour')" '{sha: $s, commit: {committer: {date: $t}}}' > "$C/head.json" + jq -n --arg t "$(iso '-13 minutes')" '[{databaseId: 4200, conclusion: "success", status: "completed", createdAt: $t, + url: "https://github.com/88plug/k3d-gpu/actions/runs/4200", headSha: "x"}]' > "$C/wd_sched.json" +} +night 2026-10-20T11:47:00Z +watch; rc=$? +red=$(issue red:build-and-push.yml) +if [ "$rc" -eq 0 ] && [ "$(state red:build-and-push.yml)" = OPEN ] \ + && [ "$(dispatched 'build-and-push.yml ref=main')" = 1 ] && [ "$(ndispatch)" = 1 ] \ + && jq -r .body <<< "$red" | grep -q 'actions/runs/39412124435' \ + && jq -r .body <<< "$red" | grep -q 'repin-images' \ + && jq -r .body <<< "$red" | grep -q 'refusing to allow a GitHub App' \ + && jq -r .body <<< "$red" | grep -q 'bot push touched .github/workflows' \ + && [ "$(jq -r '.assignees[0].login' <<< "$red")" = 88plug ] \ + && [ "$(state stale:build-and-push.yml)" = none ]; then + ok "October night 1: red:build-and-push.yml (run, failed job, log, hint) and one re-dispatch" +else + fail "October night 1: $(why) body=$(jq -r .body <<< "$red" | head -c 600)" +fi +night 2026-10-20T12:47:00Z +watch; rc=$? +if [ "$rc" -eq 0 ] && [ "$(ndispatch)" = 1 ] && [ "$(jq '.comments | length' <<< "$(issue red:build-and-push.yml)")" = 0 ] \ + && [ "$(jq '[.[] | select(.title | startswith("[ci-alarm] red:"))] | length' "$C/issues.json")" = 1 ] \ + && [ "$(state stale:build-and-push.yml)" = none ]; then + ok "October night 1, next hour: no second re-dispatch for the same run, no new comment, not stale at 24.4 h" +else + fail "October night 1, next hour: $(why)" +fi +# Night 2: a new red run; a new re-dispatch and a comment; 47 h since the last +# green scheduled run (threshold 24 h + 6 h): stale too. +night 2026-10-21T11:47:00Z +watch; rc=$? +if [ "$rc" -eq 0 ] && [ "$(ndispatch)" = 2 ] \ + && jq -r '.comments[-1].body' <<< "$(issue red:build-and-push.yml)" | grep -q 'actions/runs/39591066580' \ + && [ "$(state stale:build-and-push.yml)" = OPEN ]; then + ok "October night 2: the new red run re-dispatches once more and comments; stale opens" +else + fail "October night 2: $(why)" +fi +# 2026-10-29: the scheduled run is green again: red and stale close. +night 2026-10-29T13:00:00Z +watch; rc=$? +if [ "$rc" -eq 0 ] && [ "$(state red:build-and-push.yml)" = CLOSED ] && [ "$(state stale:build-and-push.yml)" = CLOSED ] \ + && [ "$(ndispatch)" = 2 ]; then + ok "green scheduled run closes red and stale" +else + fail "green closes: $(why)" +fi + +# 3. Stale: the last green scheduled run is 31 h old and nothing ran since. +setup stale +jq --arg t "$(iso '-31 hours')" '[.[0] | .createdAt = $t]' "$FIX/build-and-push_schedule_runs.json" > "$C/bp_sched.json" +watch; rc=$? +if [ "$rc" -eq 0 ] && [ "$(state stale:build-and-push.yml)" = OPEN ] && [ "$(state red:build-and-push.yml)" = none ] \ + && [ "$(ndispatch)" = 0 ]; then + ok "stale: a last success older than period + 6 h opens stale:build-and-push.yml" +else + fail "stale: $(why)" +fi +# No green scheduled run at all, and the workflow is old: stale. +echo '[]' > "$C/bp_sched.json" +watch; rc=$? +if [ "$rc" -eq 0 ] && [ "$(state stale:build-and-push.yml)" = OPEN ] && [ "$(nopen)" = 1 ]; then + ok "stale: no green scheduled run in 30 days keeps stale open" +else + fail "stale, none: $(why)" +fi + +# 4. A disabled workflow; a dynamic workflow or a deleted file is not alarmed. +setup disabled +jq '.workflows |= map(if .path == ".github/workflows/docs.yml" then .state = "disabled_inactivity" else . end) + | .workflows |= map(if .path | startswith("dynamic/") then .state = "disabled_manually" else . end) + | .workflows += [{id: 1, name: "Gone", path: ".github/workflows/gone.yml", state: "deleted", + created_at: "2025-01-01T00:00:00Z"}]' "$C/workflows.json" > "$C/w.json" +mv "$C/w.json" "$C/workflows.json" +watch; rc=$? +if [ "$rc" -eq 0 ] && [ "$(state disabled:docs.yml)" = OPEN ] && [ "$(nopen)" = 1 ] \ + && jq -r .body <<< "$(issue disabled:docs.yml)" | grep -q 'disabled_inactivity'; then + ok "disabled workflow: disabled:docs.yml (dynamic and deleted workflows ignored)" +else + fail "disabled: $(why)" +fi + +# 5. main's HEAD has no ci.yml run: at 10 min nothing yet (a push run may still +# appear); at 40 min one dispatch; at 2 h 10 min ci-missing. +setup cimissing +jq -n --arg s "$HEAD_SHA" --arg t "$(iso '-10 minutes')" '{sha: $s, commit: {committer: {date: $t}}}' > "$C/head.json" +runs ci_head.json "pull_request:completed:success:$HEAD_SHA" +watch; rc=$? +if [ "$rc" -eq 0 ] && [ "$(ndispatch)" = 0 ] && [ "$(nopen)" = 0 ]; then + ok "main HEAD 10 min old without CI: no dispatch yet, no alarm" +else + fail "young HEAD: $(why)" +fi +at "$(iso '+30 minutes')" +watch; rc1=$? +d1=$(dispatched 'ci.yml ref=main'); m1=$(state ci-missing) +at "$(iso '+90 minutes')" +watch; rc2=$? +if [ "$rc1" -eq 0 ] && [ "$rc2" -eq 0 ] && [ "$d1" = 1 ] && [ "$m1" = none ] \ + && [ "$(dispatched 'ci.yml ref=main')" = 1 ] && [ "$(state ci-missing)" = OPEN ] \ + && jq -r .body <<< "$(issue ci-missing)" | grep -q "$HEAD_SHA"; then + ok "main HEAD without CI: one ci.yml dispatch, then ci-missing" +else + rc=$rc2; fail "ci-missing: d1=$d1 m1=$m1 $(why)" +fi +runs ci_head.json "workflow_dispatch:in_progress::$HEAD_SHA" +watch; rc=$? +if [ "$rc" -eq 0 ] && [ "$(state ci-missing)" = CLOSED ]; then ok "a ci.yml run for HEAD closes ci-missing"; else fail "ci-missing close: $(why)"; fi + +# 6. main CI red: ci-main; green closes it. +setup cimain +runs ci_main.json "push:completed:failure:$HEAD_SHA:$(iso '-30 minutes')" "push:completed:success:abc:$(iso '-3 hours')" +watch; rc=$? +if [ "$rc" -eq 0 ] && [ "$(state ci-main)" = OPEN ] && jq -r .body <<< "$(issue ci-main)" | grep -q 'repin-images'; then + ok "red main CI opens ci-main with the failed jobs" +else + fail "ci-main: $(why)" +fi +runs ci_main.json "workflow_dispatch:completed:cancelled:$HEAD_SHA" "push:completed:success:$HEAD_SHA:$(iso '-10 minutes')" +watch; rc=$? +if [ "$rc" -eq 0 ] && [ "$(state ci-main)" = CLOSED ]; then ok "green main CI closes ci-main (a cancelled run is skipped)"; else fail "ci-main close: $(why)"; fi + +# 7. Release 0.1.76's shape: the tag exists, Docker Hub and GHCR have no image, no +# GitHub release, AUR at 0.1.75. One release re-dispatch, then the alarm; the next +# run neither re-dispatches nor comments. +setup rel76 +tags "aur-v0.1.75@-5hours aur-v0.1.76@-1hours" +registry docker.io/cryptoandcoffee/k3d-gpu:0.1.75 ghcr.io/88plug/k3d-gpu:0.1.75 +aur 0.1.75-1 +route '^release view aur-v0\.1\.75 ' rel75.json +watch; rc1=$? +at "$(iso '+1 hour')" +watch; rc2=$? +rel=$(issue release:0.1.76) +if [ "$rc1" -eq 0 ] && [ "$rc2" -eq 0 ] && [ "$(dispatched 'build-and-push.yml ref=main release=0.1.76')" = 1 ] \ + && [ "$(ndispatch)" = 1 ] && [ "$(state release:0.1.76)" = OPEN ] && [ "$(jq '.comments | length' <<< "$rel")" = 0 ] \ + && jq -r .body <<< "$rel" | grep -q 'cryptoandcoffee/k3d-gpu:0.1.76' \ + && jq -r .body <<< "$rel" | grep -q 'ghcr.io/88plug/k3d-gpu:0.1.76' \ + && jq -r .body <<< "$rel" | grep -qi 'SHA256SUMS' \ + && jq -r .body <<< "$rel" | grep -q 'AUR'; then + ok "0.1.76 shape: exactly one release re-dispatch, then release:0.1.76" +else + rc=$rc2; fail "0.1.76: $(why) body=$(jq -r .body <<< "$rel" | head -c 400)" +fi +# 0.1.77 then ships complete: release:0.1.76 closes as superseded; no dispatch. +tags "aur-v0.1.75@-6hours aur-v0.1.76@-2hours aur-v0.1.77@-1hours" +registry docker.io/cryptoandcoffee/k3d-gpu:0.1.75 ghcr.io/88plug/k3d-gpu:0.1.75 \ + docker.io/cryptoandcoffee/k3d-gpu:0.1.77 ghcr.io/88plug/k3d-gpu:0.1.77 +aur 0.1.77-1 +route '^release view aur-v0\.1\.77 ' rel77.json +watch; rc=$? +if [ "$rc" -eq 0 ] && [ "$(state release:0.1.76)" = CLOSED ] && [ "$(state release:0.1.77)" = none ] && [ "$(ndispatch)" = 1 ]; then + ok "a complete newer release closes the superseded release:0.1.76" +else + fail "superseded: $(why)" +fi + +# 8. A newer broken release lists the older broken one as information only, and a +# tag younger than 45 min is left alone. Only Docker Hub missing triggers the +# re-dispatch; GHCR alone does not (the build refuses to overwrite a published tag). +setup relinfo +tags "aur-v0.1.75@-9hours aur-v0.1.76@-8hours aur-v0.1.77@-2hours aur-v0.1.78@-20minutes" +registry docker.io/cryptoandcoffee/k3d-gpu:0.1.75 ghcr.io/88plug/k3d-gpu:0.1.75 docker.io/cryptoandcoffee/k3d-gpu:0.1.77 +aur 0.1.77-1 +route '^release view aur-v0\.1\.77 ' rel77.json +route '^release view aur-v0\.1\.75 ' rel75.json +watch; rc=$? +rel=$(issue release:0.1.77) +if [ "$rc" -eq 0 ] && [ "$(state release:0.1.77)" = OPEN ] && [ "$(ndispatch)" = 0 ] && [ "$(state release:0.1.78)" = none ] \ + && jq -r .body <<< "$rel" | grep -q 'ghcr.io/88plug/k3d-gpu:0.1.77' \ + && jq -r .body <<< "$rel" | grep -q '0\.1\.76' \ + && ! jq -r .body <<< "$rel" | grep -q 'cryptoandcoffee/k3d-gpu:0.1.77 '; then + ok "GHCR-only gap: alarm, no re-dispatch; older 0.1.76 listed; a 20 min old tag skipped" +else + fail "relinfo: $(why) body=$(jq -r .body <<< "$rel" | head -c 600)" +fi + +# 9. A release workflow still running: the release check waits (no dispatch, no alarm). +setup busy +tags "aur-v0.1.75@-5hours aur-v0.1.76@-1hours" +registry docker.io/cryptoandcoffee/k3d-gpu:0.1.75 ghcr.io/88plug/k3d-gpu:0.1.75 +jq -n '[{databaseId: 1, workflowName: "Auto-update, Build & Release", status: "in_progress"}]' > "$C/busy.json" +watch; rc=$? +if [ "$rc" -eq 0 ] && [ "$(ndispatch)" = 0 ] && [ "$(state release:0.1.76)" = none ] && grep -q 'release check deferred' "$C/out"; then + ok "a running release workflow defers the release check" +else + fail "busy: $(why)" +fi + +# 10. Bot PRs: an autobump PR open 73 h whose head has no green ci.yml run. +setup botpr +jq -n --arg old "$(iso '-73 hours')" --arg young "$(iso '-10 hours')" --arg s "$PR_SHA" '[ + {number: 240, headRefName: "autobump/convert-pins", headRefOid: $s, createdAt: $old, + url: "https://github.com/88plug/k3d-gpu/pull/240", title: "chore(convert-pins): bump", + author: {is_bot: true, login: "app/github-actions"}}, + {number: 241, headRefName: "autobump/image-digests", headRefOid: "feed", createdAt: $young, + url: "https://github.com/88plug/k3d-gpu/pull/241", title: "young", author: {login: "app/github-actions"}}, + {number: 242, headRefName: "autobump/nri-balloons", headRefOid: "beef", createdAt: $old, + url: "https://github.com/88plug/k3d-gpu/pull/242", title: "green", author: {login: "app/github-actions"}}, + {number: 243, headRefName: "claude/h54q-run", headRefOid: "cafe", createdAt: $old, + url: "https://github.com/88plug/k3d-gpu/pull/243", title: "human", author: {login: "88plug"}}]' > "$C/prs.json" +runs ci_pr.json "pull_request:completed:action_required:$PR_SHA" +runs ci_green.json "workflow_dispatch:completed:success:beef" +route "^run list .*--workflow ci\\.yml .*--commit $PR_SHA" ci_pr.json +route '^run list .*--workflow ci\.yml .*--commit beef' ci_green.json +route '^run list .*--workflow ci\.yml .*--commit cafe' - 1 'must not be asked' +watch; rc=$? +b=$(issue bot-pr-stuck) +if [ "$rc" -eq 0 ] && [ "$(state bot-pr-stuck)" = OPEN ] && jq -r .body <<< "$b" | grep -q 'pull/240' \ + && ! jq -r .body <<< "$b" | grep -qE 'pull/24[123]' && ! grep -q 'must not be asked' "$C/err"; then + ok "a 73 h autobump PR without green CI opens bot-pr-stuck (young, green and human PRs left out)" +else + fail "bot-pr-stuck: $(why) body=$(jq -r .body <<< "$b" | head -c 400)" +fi +echo '[]' > "$C/prs.json" +watch; rc=$? +if [ "$rc" -eq 0 ] && [ "$(state bot-pr-stuck)" = CLOSED ]; then ok "no stuck bot PR closes bot-pr-stuck"; else fail "bot-pr-stuck close: $(why)"; fi + +# 11. simulate_red: opens simulate (assigned to 88plug); the next run without it closes it. +setup simulate +SIMULATE_RED=true watch; rc1=$? +s1=$(state simulate); who=$(jq -r '.assignees[0].login' <<< "$(issue simulate)") +SIMULATE_RED=false watch; rc2=$? +if [ "$rc1" -eq 0 ] && [ "$rc2" -eq 0 ] && [ "$s1" = OPEN ] && [ "$who" = 88plug ] && [ "$(state simulate)" = CLOSED ]; then + ok "simulate_red opens [ci-alarm] simulate; the next run closes it" +else + rc=$rc2; fail "simulate: s1=$s1 who=$who $(why)" +fi + +# 12. A gh error: watchdog-error carries the error text, the other checks still run, +# exit 0; the next clean run closes it. +setup gherror +route '^api repos/88plug/k3d-gpu/actions/workflows' - 1 'HTTP 502: Bad Gateway (https://api.github.com/repos/88plug/k3d-gpu/actions/workflows)' +runs ci_main.json "push:completed:failure:$HEAD_SHA:$(iso '-30 minutes')" +watch; rc=$? +if [ "$rc" -eq 0 ] && [ "$(state watchdog-error)" = OPEN ] && jq -r .body <<< "$(issue watchdog-error)" | grep -q 'HTTP 502' \ + && [ "$(state ci-main)" = OPEN ]; then + ok "a gh error opens watchdog-error with its text; the other checks still run" +else + fail "watchdog-error: $(why)" +fi +route '^api repos/88plug/k3d-gpu/actions/workflows' workflows.json +watch; rc=$? +if [ "$rc" -eq 0 ] && [ "$(state watchdog-error)" = CLOSED ]; then ok "a clean run closes watchdog-error"; else fail "watchdog-error close: $(why)"; fi + +# 13. Issues disabled: no alarm can be filed; the run prints ::error and exits 1. +setup noissues +route '^(issue|label) ' - 1 "the '88plug/k3d-gpu' repository has disabled issues" +SIMULATE_RED=true watch; rc=$? +if [ "$rc" -eq 1 ] && grep -q '^::error title=ci-alarm simulate::' "$C/out" && grep -qi 'issues' "$C/err"; then + ok "issues disabled: ::error annotation and exit 1" +else + fail "issues disabled: rc=$rc out=$(tail -3 "$C/out" | tr '\n' ' ') err=$(tail -3 "$C/err" | tr '\n' ' ')" +fi + +# 13b. Re-dispatches happen only once the alarm records them. Two runs in a row +# (the second stands for the run a re-dispatch of watchdog.yml starts) with red +# scheduled runs of watchdog.yml and build-and-push.yml and a newest release +# without its Docker Hub image: with issues disabled (this repository today) and +# with alarm writes failing, nothing is dispatched. Dispatching first and +# recording after let every red watchdog run re-dispatch watchdog.yml, and each +# dispatched run do the same, without end. +redsetup() { + setup "$1" + jq '[.[] | select(.databaseId <= 40686436167)]' "$FIX/build-and-push_schedule_runs.json" > "$C/bp_sched.json" + jq -n --arg t "$(iso '-13 minutes')" '[{databaseId: 4201, conclusion: "failure", status: "completed", + createdAt: $t, url: "https://github.com/88plug/k3d-gpu/actions/runs/4201", headSha: "x", + workflowName: "Watchdog", event: "schedule"}]' > "$C/wd_sched.json" + tags "aur-v0.1.79@-3hours aur-v0.1.80@-1hours" +} +redsetup noissues_red +route '^(issue|label) ' - 1 "the '88plug/k3d-gpu' repository has disabled issues" +watch; rc1=$? +watch; rc2=$? +if [ "$rc1" -eq 1 ] && [ "$rc2" -eq 1 ] && [ "$(ndispatch)" = 0 ] && grep -q '^::error title=ci-alarm red:watchdog.yml::' "$C/out"; then + ok "issues disabled, red scheduled runs and a release gap: ::error, and no re-dispatch it cannot record" +else + rc=$rc2; fail "issues disabled, red: $(why) dispatched=$(tr '\n' ',' < "$C/dispatches.log" 2>/dev/null)" +fi +redsetup alarmwrite_red +route '^(issue (create|edit|comment)|label create) ' - 1 'HTTP 502: Bad Gateway (https://api.github.com/graphql)' +watch; rc1=$? +watch; rc2=$? +if [ "$(ndispatch)" = 0 ] && [ "$rc2" -eq 1 ] && grep -q 'HTTP 502' "$C/err"; then + ok "alarm writes failing: no re-dispatch, the error is reported" +else + rc=$rc2; fail "alarm writes failing: rc1=$rc1 $(why) dispatched=$(tr '\n' ',' < "$C/dispatches.log" 2>/dev/null)" +fi +# Issues on: the same state re-dispatches watchdog.yml, build-and-push.yml and +# the release once each, and the second run adds no dispatch. +redsetup issues_red +watch; rc1=$? +watch; rc2=$? +if [ "$rc1" -eq 0 ] && [ "$rc2" -eq 0 ] && [ "$(ndispatch)" = 3 ] && [ "$(dispatched 'watchdog.yml ref=main')" = 1 ] \ + && [ "$(dispatched 'build-and-push.yml ref=main')" = 1 ] && [ "$(dispatched 'build-and-push.yml ref=main release=0.1.80')" = 1 ] \ + && jq -r .body <<< "$(issue red:watchdog.yml)" | grep -q 'watchdog:dispatched=4201' \ + && jq -r .body <<< "$(issue release:0.1.80)" | grep -q 'watchdog:dispatched=0.1.80'; then + ok "issues on: one re-dispatch each for the red workflows and the release, none on the next run" +else + rc=$rc2; fail "issues on, red: rc1=$rc1 $(why) dispatched=$(tr '\n' ',' < "$C/dispatches.log" 2>/dev/null)" +fi +# A re-dispatch that fails leaves no record: the alarm says so, the next run tries again. +redsetup dispatchfail +route '^workflow run watchdog\.yml ' - 1 'HTTP 422: Workflow does not have workflow_dispatch trigger' +watch; rc1=$? +b1=$(jq -r .body <<< "$(issue red:watchdog.yml)"); e1=$(state watchdog-error) +route '^workflow run watchdog\.yml ' - 0 +watch; rc2=$? +if [ "$rc1" -eq 0 ] && grep -q 'Re-dispatch failed: .*HTTP 422' <<< "$b1" && ! grep -q 'watchdog:dispatched' <<< "$b1" \ + && [ "$e1" = OPEN ] && [ "$(state watchdog-error)" = CLOSED ] \ + && jq -r .body <<< "$(issue red:watchdog.yml)" | grep -q 'watchdog:dispatched=4201'; then + ok "a failed re-dispatch is not recorded: the alarm says so and the next run dispatches" +else + rc=$rc2; fail "dispatch failure: rc1=$rc1 $(why) body1=$(head -c 300 <<< "$b1")" +fi +# A run that cannot list the open alarms (a transient 502 on the watchdog's own +# listing; ci_alarm still works) leaves the dispatching alarms as they are: their +# records survive, so the run after it dispatches nothing new. +redsetup listfail +watch; rc1=$? +route '^issue list .*--json number,title,body$' - 1 'HTTP 502: Bad Gateway (https://api.github.com/graphql)' +watch; rc2=$? +b2=$(jq -r .body <<< "$(issue red:watchdog.yml)") +sed -i '1d' "$C/routes.tsv" +watch; rc3=$? +if [ "$rc1" -eq 0 ] && [ "$rc2" -eq 0 ] && [ "$rc3" -eq 0 ] && [ "$(ndispatch)" = 3 ] \ + && grep -q 'watchdog:dispatched=4201' <<< "$b2" \ + && jq -r .body <<< "$(issue release:0.1.80)" | grep -q 'watchdog:dispatched=0.1.80'; then + ok "a run that cannot list the alarms keeps their dispatch records: no second dispatch" +else + rc=$rc3; fail "listing failure: rc1=$rc1 rc2=$rc2 $(why) dispatched=$(tr '\n' ',' < "$C/dispatches.log" 2>/dev/null)" +fi + +# 13c. An autobump/* branch on a fork is not a bot PR: a fork could otherwise hold +# bot-pr-stuck open for as long as it liked. +setup forkpr +jq -n --arg old "$(iso '-73 hours')" '[ + {number: 244, headRefName: "autobump/convert-pins", headRefOid: "f0f0", createdAt: $old, isCrossRepository: true, + url: "https://github.com/88plug/k3d-gpu/pull/244", title: "fork", author: {login: "someone"}}]' > "$C/prs.json" +route '^run list .*--workflow ci\.yml .*--commit f0f0' - 1 'must not be asked' +watch; rc=$? +if [ "$rc" -eq 0 ] && [ "$(state bot-pr-stuck)" = none ] && ! grep -q 'must not be asked' "$C/err" \ + && grep -q '^pr list .*--json [^ ]*isCrossRepository' "$C/calls.log"; then + ok "a fork's autobump/* PR is not a stuck bot PR" +else + fail "fork PR: $(why) prlist=$(grep '^pr list' "$C/calls.log" | head -1)" +fi + +# 14. --list-scheduled: every workflow with on.schedule, with its cron period in hours. +setup listsched +watch --list-scheduled; rc=$? +if [ "$rc" -eq 0 ] && grep -qx 'build-and-push.yml 24' "$C/out" && grep -qx 'watchdog.yml 1' "$C/out"; then + ok "--list-scheduled: build-and-push.yml 24, watchdog.yml 1" +else + fail "--list-scheduled: rc=$rc out=$(tr '\n' ' ' < "$C/out")" +fi + +echo "${PASS} passed, ${FAILS} failed" +[ "${FAILS}" -eq 0 ] diff --git a/tests/wildct_hold_test.sh b/tests/wildct_hold_test.sh index 90edff80..96e779a1 100755 --- a/tests/wildct_hold_test.sh +++ b/tests/wildct_hold_test.sh @@ -608,6 +608,19 @@ row_envs_match_the_registration() { "bash ${ROOT}/bench/h57d/hold.sh 2 perf" || return 1 has h57w 5 perf Ld Ld-mtp Od -- "bash ${ROOT}/bench/h57d/hold.sh 5 perf Ld Ld-mtp Od" || return 1 has h57w-convert -- "G=${HOME}/k3dgpu-bench-wildct/h57" "LOCK=${HOME}/k3dgpu-bench-wildct/live.lock" || return 1 + # H59w (H59 amendment R1): bench/h59/hold.sh's wildct profile carries the opt-out, W3 and load1 12. + has h59w 2 -- "G=${HOME}/k3dgpu-bench-wildct/h59" "LOCK=${HOME}/k3dgpu-bench-wildct/inner.lock" \ + "K3D_REAL=${HOME}/k3dgpu-bench-wildct/bin/k3d" "H59_IMAGE=${IMG}" "bash ${ROOT}/bench/h59/hold.sh wildct 2" || return 1 + has h59w 5 3 -- "bash ${ROOT}/bench/h59/hold.sh wildct 5 3" || return 1 + has h59w-prep -- "G=${HOME}/k3dgpu-bench-wildct/h59" "LOCK=${HOME}/k3dgpu-bench-wildct/live.lock" \ + "bash ${ROOT}/bench/h59/prep.sh wildct" || return 1 + python3 -c 'import sys +sys.path.insert(0, sys.argv[1] + "/bench/h59") +import run +p = run.PROFILES["wildct"] +sys.exit(not (p["k3s_args"] == sys.argv[2] and p["vendors"] == "auto,-nvidia,-intel" and p["load1_max"] == 12 + and p["prefix"].startswith("k3dgpu-w-")))' "${ROOT}" "${W3_LEDGER}" \ + || { echo "bench/h59/run.py's wildct profile is not the registration's"; return 1; } has h18w -- "bash ${ROOT}/bench/sandbox_round.sh (runs 1-3)" || return 1 # H52w's procedure: `hold.sh H52w 1800 3 bench/wildct/h52w.sh <out>`; h52w.sh sets its own # cluster, opt-out and W3, and runs the installed release's default node image (no IMAGE). @@ -687,6 +700,8 @@ row_guards() { IMAGE=cryptoandcoffee/k3d-gpu:0.1.74 bash "${ROW}" h25w 2> /dev/null; [ $? = 2 ] || { echo "IMAGE without a digest accepted"; return 1; } IMAGE=${IMG} bash "${ROW}" h57w 1 perf 2> /dev/null; [ $? = 2 ] || { echo "h57w ran without the k3d shim"; return 1; } WILDCT_HOLD=X bash "${ROW}" h57w-convert 2> /dev/null; [ $? = 2 ] || { echo "h57w-convert ran inside a hold"; return 1; } + IMAGE=${IMG} bash "${ROW}" h59w 1 2> /dev/null; [ $? = 2 ] || { echo "h59w ran without the k3d shim"; return 1; } + WILDCT_HOLD=X bash "${ROW}" h59w-prep 2> /dev/null; [ $? = 2 ] || { echo "h59w-prep ran inside a hold"; return 1; } # WILDCT_ROW_CHECK=1 (launch's pre-check): every check, the k3d shim included; runs nothing. local a chk() { WILDCT_ROW_CHECK=1 IMAGE=${IMG} bash "${ROW}" "$@" > "${W}/chk.out" 2>&1; } @@ -701,6 +716,16 @@ row_guards() { # shellcheck disable=SC2086 # the row's arguments, split on purpose chk h57w ${a}; [ $? = 2 ] || { echo "check mode passed h57w ${a}"; return 1; } done + chk h59w 1; [ $? = 2 ] || { echo "check mode passed h59w without the k3d shim"; return 1; } + install -D -m 0755 "${ROOT}/bench/h59/k3d-shim.sh" "${HOME}/k3dgpu-bench-wildct/h59/bin/k3d" + for a in 1 2 3 4 "5 1" "6 4"; do + # shellcheck disable=SC2086 # the row's arguments, split on purpose + chk h59w ${a} || { echo "check mode refused h59w ${a}: $(cat "${W}/chk.out")"; return 1; } + done + for a in "" 5 7 0 "1 2" "5 5" "6 0" "1 perf" "5 1 O"; do + # shellcheck disable=SC2086 # the row's arguments, split on purpose + chk h59w ${a}; [ $? = 2 ] || { echo "check mode passed h59w ${a}"; return 1; } + done chk h25w || { echo "check mode refused h25w: $(cat "${W}/chk.out")"; return 1; } [ "$(calls '')" = 0 ] || { echo "check mode ran a command: $(cat "${W}/calls.log")"; return 1; } bash "${ROW}" nope 2> /dev/null; [ $? = 2 ] || { echo "unknown row accepted"; return 1; }