Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 93 additions & 16 deletions .github/workflows/image.yml
Original file line number Diff line number Diff line change
@@ -1,30 +1,50 @@
name: Image

# Builds the Docker image and pushes it to GitHub Container Registry. Runs on a
# version tag, or by hand against any ref (leave "push" off to build without publishing).
# Builds the Docker images and pushes them to GitHub Container Registry for linux/amd64 and
# linux/arm64. Runs on a version tag, or by hand against any ref (leave "push" off to build
# without publishing). Each architecture builds on a native runner and pushes by digest; a
# second job then joins the two into one multi-arch tag.

on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
push:
description: "Push the image to ghcr.io"
description: "Push the images to ghcr.io"
type: boolean
default: false

permissions:
contents: read

env:
IMAGE: ghcr.io/${{ github.repository }}
PUSH: ${{ github.event_name == 'push' || inputs.push }}

jobs:
image:
runs-on: ubuntu-latest
build:
# Two images per release: the default (decisions only, no speech model inside) and an
# `-asr` variant with the pinned speech model baked in, for chat speech input.
name: build (${{ matrix.variant }}, ${{ matrix.arch }})
strategy:
fail-fast: false
matrix:
variant: [default, asr]
arch: [amd64, arm64]
include:
- arch: amd64
runner: ubuntu-latest
- arch: arm64
runner: ubuntu-24.04-arm
- variant: default
with_asr: "0"
- variant: asr
with_asr: "1"
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
env:
IMAGE: ghcr.io/${{ github.repository }}
PUSH: ${{ github.event_name == 'push' || inputs.push }}
steps:
# The base image is large; the runner's default free disk is not enough.
- name: Free disk space
Expand All @@ -40,19 +60,76 @@ jobs:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/${{ matrix.arch }}
build-args: WITH_ASR=${{ matrix.with_asr }}
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=${{ env.PUSH == 'true' }}
- name: Export digest
if: env.PUSH == 'true'
run: |
mkdir -p "$RUNNER_TEMP/digests"
digest="${{ steps.build.outputs.digest }}"
touch "$RUNNER_TEMP/digests/${digest#sha256:}"
- uses: actions/upload-artifact@v7
if: env.PUSH == 'true'
with:
name: digests-${{ matrix.variant }}-${{ matrix.arch }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1

merge:
name: tag (${{ matrix.variant }})
if: ${{ github.event_name == 'push' || inputs.push }}
needs: build
strategy:
fail-fast: false
matrix:
include:
# `latest` stays the default image; the asr image gets `latest-asr` (onlatest=true),
# so the two variants never push the same tag.
- variant: default
flavor: "latest=auto"
- variant: asr
flavor: "suffix=-asr,onlatest=true"
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/download-artifact@v8
with:
path: ${{ runner.temp }}/digests
pattern: digests-${{ matrix.variant }}-*
merge-multiple: true
- uses: docker/setup-buildx-action@v4
- name: Log in to ghcr.io
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Tags
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.IMAGE }}
flavor: ${{ matrix.flavor }}
tags: |
type=semver,pattern={{version}}
type=ref,event=branch
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64
push: ${{ env.PUSH == 'true' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Create the multi-arch manifest
working-directory: ${{ runner.temp }}/digests
run: |
mapfile -t tags < <(jq -r '.tags[]' <<< "$DOCKER_METADATA_OUTPUT_JSON")
args=()
for t in "${tags[@]}"; do args+=(-t "$t"); done
refs=()
for d in *; do refs+=("${IMAGE}@sha256:${d}"); done
docker buildx imagetools create "${args[@]}" "${refs[@]}"
- name: Inspect
run: docker buildx imagetools inspect "${{ env.IMAGE }}:${{ steps.meta.outputs.version }}"
67 changes: 26 additions & 41 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,51 +1,39 @@
# A vLLM OpenAI-compatible server for a composed granite-switch checkpoint, serving
# BOTH the standard /v1/chat/completions API and typesafe.ai's jev system-one API
# (/v1/systemone) on the same server -- one engine, one GPU KV cache, one container.
# The checkpoint's weights are NOT baked into this image (mounted separately at
# $MODEL_DIR / /repository, e.g. by HF Inference Endpoints' own deployment mechanism).
# vLLM server for a composed granite-switch checkpoint: /v1/chat/completions plus the
# /v1/decisions and /v1/systemone plugin routes, on one engine and one KV cache.
# The checkpoint is not in the image; mount it at $MODEL_DIR (default /repository).
#
# docker build --platform linux/amd64 -t <your-registry>/granite-switch-systemone:<tag> .
# docker build -t <registry>/granite-switch-systemone:<tag> .
#
# Optional speech input (granite-switch[audio], and a specific speech model's weights baked in
# at a pinned revision -- see ASR_MODEL/ASR_REVISION below). Decisions do not use it, so
# serve.sh switches it off unless SYSTEMONE_SPEECH_INPUT=1; build with WITH_ASR=1 for an image
# that has what it needs:
# docker build --platform linux/amd64 --build-arg WITH_ASR=1 \
# -t <your-registry>/granite-switch-systemone:<tag>-asr .
# vLLM 0.30, the version this plugin is tested on live (granite-switch 0.1.1 supports
# 0.26-0.30). The default tag is the CUDA 13 build (torch 2.13.0+cu130, the PyPI vLLM
# wheel) -- the same stack the live tests ran. The -cu129 tag ships torch 2.14.0,
# which the vLLM wheel's own torch==2.13.0 pin contradicts, so granite-switch[vllm]
# has no resolution against it.
# The audio libraries are in every build. WITH_ASR=1 also bakes in the speech model (see
# ASR_MODEL and ASR_REVISION); serve.sh keeps speech off unless SYSTEMONE_SPEECH_INPUT=1.
# docker build --build-arg WITH_ASR=1 -t <registry>/granite-switch-systemone:<tag>-asr .
#
# vLLM 0.30 is the version tested live. The default tag is the CUDA 13 build (torch
# 2.13.0+cu130), the stack the tests ran. The -cu129 tag ships torch 2.14.0, which the
# vLLM wheel's own torch==2.13.0 pin rules out, so granite-switch[vllm] cannot resolve there.
FROM vllm/vllm-openai:v0.30.0
SHELL ["/bin/bash", "-o", "pipefail", "-c"]

ARG GRANITE_SWITCH_VERSION=0.1.1
ARG WITH_ASR=0

# Pinned to a specific, released granite-switch version (not `main`) so this image's
# behavior does not drift when upstream changes -- deliberately a tagged PyPI release,
# not a floating branch. transformers 5.17 is what granite-switch supports; [audio] is
# only installed when WITH_ASR=1 (it brings librosa and soundfile, used only by the
# ASR cascade). The base image's vLLM, torch, and torchcodec (CUDA 13 builds) are kept
# as they are: the constraint file pins them so nothing in granite-switch's dependency
# tree can move them.
# granite-switch is pinned to a PyPI release so the image does not drift with upstream;
# transformers 5.17 is the version it supports. The [audio] extra (av, scipy, soundfile,
# soxr) is in every build: without it audio requests fail with HTTP 500 while /health and
# decisions look fine. The constraint file keeps the base image's vLLM, torch and
# torchcodec as they are.
RUN python3 -m pip list --format=freeze | grep -i -E "^(vllm|torch|torchaudio|torchcodec|torchvision|flashinfer-python)==" > /tmp/keep.txt \
&& if [ "$WITH_ASR" = "1" ]; then EXTRA="granite-switch[audio]"; else EXTRA="granite-switch[vllm]"; fi \
&& uv pip install --system --constraint /tmp/keep.txt \
"${EXTRA}==${GRANITE_SWITCH_VERSION}" \
"granite-switch[audio]==${GRANITE_SWITCH_VERSION}" \
"transformers==5.17.0" \
&& python3 -c "import granite_switch.vllm, vllm, transformers, torch; print(vllm.__version__, transformers.__version__, torch.__version__)"
&& python3 -c "import granite_switch.vllm, vllm, transformers, torch, av, scipy, soundfile, soxr; print(vllm.__version__, transformers.__version__, torch.__version__)"

# The ASR cascade's speech model, named by Hugging Face id in the checkpoint's config,
# baked in as a Hub cache at the revision this image was tested with, without the
# repo's unused .py files. The server runs with the Hub offline, so a missing/moved file
# fails startup instead of silently fetching something else.
# The pinned commit is downloaded by hash, not from `main`, so the build does not change when
# the model repository moves. A download by commit writes no refs/main, and the offline lookup
# for the revision "main" reads that file, so it is written here and then checked.
# The speech model the checkpoint's config names, baked in as a Hub cache at the tested
# commit (without the repo's unused .py files). The server runs with the Hub offline, so a
# missing file fails startup instead of fetching something else. A download by commit
# writes no refs/main, which the offline lookup for "main" reads, so it is written here.
ARG ASR_MODEL=ibm-granite/granite-speech-5.0-470m-turboctc
ARG ASR_REVISION=947f59af40db9791170a0628cf0f3f4812d720f1
ARG ASR_REVISION=1dd23f46f6d4e6eae2531982ba2e33220086dd97
RUN if [ "$WITH_ASR" = "1" ]; then \
hf download ${ASR_MODEL} --revision ${ASR_REVISION} --cache-dir /opt/hf-cache --exclude "*.py" > /dev/null \
&& mkdir -p /opt/hf-cache/models--${ASR_MODEL//\//--}/refs \
Expand All @@ -54,18 +42,15 @@ RUN if [ "$WITH_ASR" = "1" ]; then \
fi
ENV HF_HUB_CACHE=/opt/hf-cache HF_HUB_OFFLINE=1

# The jev system-one API (/v1/systemone), a vllm.endpoint_plugins entry point mounted
# on this same server -- see README.md and src/granite_switch_systemone/plugin.py.
# The plugin: a vllm.endpoint_plugins entry point (see src/granite_switch_systemone/plugin.py).
COPY pyproject.toml README.md /opt/granite-switch-systemone/
COPY src /opt/granite-switch-systemone/src
RUN uv pip install --system --no-deps /opt/granite-switch-systemone

COPY serve.sh /usr/local/bin/serve.sh
RUN chmod +x /usr/local/bin/serve.sh
# Endpoint plugins only load if VLLM_PLUGINS is set AND names them (stricter than
# other vLLM plugin groups, which default to "load everything" when VLLM_PLUGINS is
# unset): must include register_granite_switch too, or the Switch model class stops
# being registered the moment this allowlist is set at all.
# Endpoint plugins load only when VLLM_PLUGINS names them, and setting it stops vLLM loading
# any others, so register_granite_switch has to be listed too.
ENV VLLM_PLUGINS=register_granite_switch,granite_switch_systemone \
SYSTEMONE_ADAPTER_NAME=decision \
SYSTEMONE_INVOCATION_MARKER="### DECISION"
Expand Down
Loading
Loading