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
20 changes: 20 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
.git
.github
.claude
.codex
.gstack
.worktrees
target
docs
scripts
*.db
*.db-shm
*.db-wal
# Keep local credentials and private keys out of the build context.
.env*
.npmrc
.cargo
*.pem
*.key
Dockerfile*
README.md
6 changes: 6 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: docker
directory: /
schedule:
interval: weekly
180 changes: 180 additions & 0 deletions .github/workflows/container.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
name: Container

on:
merge_group:
pull_request:
paths:
- ".dockerignore"
- ".github/workflows/container.yml"
- "Cargo.lock"
- "Cargo.toml"
- "Dockerfile"
- "docker-entrypoint.sh"
- "crates/**"
- "scripts/container-fixture-server.py"
- "scripts/fixtures/vllm-response-single-nonstreaming.json"
- "scripts/test-container-entrypoint.sh"
push:
branches: [main]
paths:
- ".dockerignore"
- ".github/workflows/container.yml"
- "Cargo.lock"
- "Cargo.toml"
- "Dockerfile"
- "docker-entrypoint.sh"
- "crates/**"
- "scripts/container-fixture-server.py"
- "scripts/fixtures/vllm-response-single-nonstreaming.json"
- "scripts/test-container-entrypoint.sh"

permissions:
contents: read

jobs:
build-and-smoke-test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Test container entrypoint
run: scripts/test-container-entrypoint.sh

- name: Build runtime image
env:
DOCKER_BUILDKIT: "1"
run: |
docker build \
--build-arg OCI_BUILD_PIPELINE="$GITHUB_WORKFLOW" \
--build-arg OCI_BUILD_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
--build-arg OCI_CREATED="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--build-arg OCI_REVISION="$GITHUB_SHA" \
--build-arg OCI_VERSION="${GITHUB_REF_NAME}" \
--tag agentic-api:test \
.

- name: Verify runtime contents and identity
run: |
docker run --rm --entrypoint sh agentic-api:test -eu -c '
for command in cargo rustc python vllm; do
! command -v "$command"
done
test "$(id -u)" -ne 0
test "$(id -g)" -eq 0
test ! -w /usr/local/bin/agentic-server
test ! -w /usr/local/bin/docker-entrypoint.sh
'
container_id=$(docker create agentic-api:test)
binary_copy=$(mktemp)
cleanup_binary() {
docker rm --force "$container_id" >/dev/null 2>&1 || true
rm -f "$binary_copy"
}
trap cleanup_binary EXIT
docker cp "$container_id:/usr/local/bin/agentic-server" "$binary_copy"
binary_description=$(file "$binary_copy")
echo "$binary_description"
case "$binary_description" in
*"not stripped"*) exit 1 ;;
*"stripped"*) ;;
*) echo "unable to verify that agentic-server is stripped" >&2; exit 1 ;;
esac
volume_dir=$(mktemp -d)
cleanup_volume() {
sudo rm -rf "$volume_dir"
}
trap 'cleanup_binary; cleanup_volume' EXIT
sudo chgrp 0 "$volume_dir"
chmod 2775 "$volume_dir"
docker run --rm --user 12345:0 \
--volume "$volume_dir:/var/lib/agentic-api" \
--entrypoint sh agentic-api:test \
-eu -c 'touch /var/lib/agentic-api/write-check && test -w /var/lib/agentic-api/write-check'

- name: Test Responses API end to end
run: |
python3 scripts/container-fixture-server.py \
--fixture scripts/fixtures/vllm-response-single-nonstreaming.json \
--port 18000 \
>/tmp/agentic-api-upstream.log 2>&1 &
upstream_pid=$!
volume=agentic-api-smoke-data
upstream_response_id=$(jq --exit-status --raw-output \
'.id | select(type == "string" and length > 0)' \
scripts/fixtures/vllm-response-single-nonstreaming.json)

cleanup() {
docker logs agentic-api-smoke 2>/dev/null || true
docker rm --force agentic-api-smoke >/dev/null 2>&1 || true
docker volume rm "$volume" >/dev/null 2>&1 || true
kill "$upstream_pid" 2>/dev/null || true
}
trap cleanup EXIT

start_gateway() {
docker run --detach --name agentic-api-smoke --network host --user "$1:0" \
--volume "$volume:/var/lib/agentic-api" \
--env LLM_API_BASE=http://127.0.0.1:18000 \
agentic-api:test
}

wait_until_ready() {
for attempt in $(seq 1 30); do
if curl --connect-timeout 1 --max-time 3 --fail --silent http://127.0.0.1:9000/health >/dev/null && \
curl --connect-timeout 1 --max-time 3 --fail --silent http://127.0.0.1:9000/ready >/dev/null; then
return 0
fi
echo "gateway not ready (attempt $attempt/30)"
sleep 1
done
return 1
}

post_response() {
jq --null-input --compact-output --arg previous_response_id "$1" '
{
input: "Reply with exactly one word: HELLO",
model: "gpt-4o",
store: true,
stream: false
} + if $previous_response_id == "" then {} else {
previous_response_id: $previous_response_id
} end
' >/tmp/agentic-api-request.json
curl --connect-timeout 2 --max-time 15 --fail --silent --show-error \
--header 'Content-Type: application/json' \
--data-binary @/tmp/agentic-api-request.json \
--output "$2" \
http://127.0.0.1:9000/v1/responses
jq --exit-status --arg upstream_response_id "$upstream_response_id" '
.status == "completed" and
(.id | startswith("resp_")) and
.id != $upstream_response_id and
.model == "gpt-4o" and
.output[0].content[0].text == "HI"
' "$2"
}

docker volume create "$volume"
start_gateway 12345
wait_until_ready
post_response "" /tmp/agentic-api-response-1.json
first_response_id=$(jq --exit-status --raw-output '.id' /tmp/agentic-api-response-1.json)
docker exec agentic-api-smoke test -f /var/lib/agentic-api/agentic_api.db
docker stop --timeout 10 agentic-api-smoke
test "$(docker inspect --format '{{.State.ExitCode}}' agentic-api-smoke)" -eq 0
docker rm agentic-api-smoke

start_gateway 23456
wait_until_ready
post_response "$first_response_id" /tmp/agentic-api-response-2.json
second_response_id=$(jq --exit-status --raw-output '.id' /tmp/agentic-api-response-2.json)
post_response "$second_response_id" /tmp/agentic-api-response-3.json
if docker logs agentic-api-smoke 2>&1 | grep --quiet 'persist failed'; then
echo "SQLite persistence failed after arbitrary-UID rotation" >&2
exit 1
fi
docker stop --timeout 10 agentic-api-smoke
test "$(docker inspect --format '{{.State.ExitCode}}' agentic-api-smoke)" -eq 0
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ futures = "0.3"
indexmap = "2"
http = "1"
reqwest = { version = "0.12", default-features = false }
rmcp-reqwest = { package = "reqwest", version = "0.13.2", default-features = false, features = ["json", "stream", "rustls"] }
rmcp = { version = "1.8", default-features = false }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Expand All @@ -37,3 +38,4 @@ tower-http = { version = "0.6", features = ["cors"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tokio-tungstenite = "0.29"
url = "2"
65 changes: 65 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# syntax=docker/dockerfile:1.7

ARG RUST_VERSION=1.96.0
ARG DEBIAN_VERSION=bookworm
ARG RUST_IMAGE_DIGEST=sha256:5e2214abe154fe26e39f64488952e5c991eeed1d6d6da7cc8381ae83927f0cfc
ARG DEBIAN_IMAGE_DIGEST=sha256:7b140f374b289a7c2befc338f42ebe6441b7ea838a042bbd5acbfca6ec875818

FROM rust:${RUST_VERSION}-${DEBIAN_VERSION}@${RUST_IMAGE_DIGEST} AS rust-build

ARG CARGO_BUILD_JOBS=4
ENV CARGO_BUILD_JOBS=${CARGO_BUILD_JOBS}

WORKDIR /workspace
COPY Cargo.toml Cargo.lock ./
COPY crates ./crates

RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,target=/usr/local/cargo/git,sharing=locked \
--mount=type=cache,target=/workspace/target,sharing=locked \
cargo build --locked --release -p agentic-server && \
install -Dm755 -s target/release/agentic-server /out/agentic-server

FROM debian:${DEBIAN_VERSION}-slim@${DEBIAN_IMAGE_DIGEST} AS runtime

ARG RUNTIME_GID=0
ARG RUNTIME_UID=10001

RUN apt-get update && \
apt-get install --yes --no-install-recommends ca-certificates && \
rm -rf /var/lib/apt/lists/* && \
mkdir -p /var/lib/agentic-api && \
chown "${RUNTIME_UID}:${RUNTIME_GID}" /var/lib/agentic-api && \
chmod g=u,g+s /var/lib/agentic-api

COPY --from=rust-build /out/agentic-server /usr/local/bin/agentic-server
COPY --chmod=0755 docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh

ARG OCI_CREATED=""
ARG OCI_BUILD_PIPELINE=local
ARG OCI_BUILD_URL=""
ARG OCI_REVISION=""
ARG OCI_SOURCE="https://github.com/vllm-project/agentic-api"
ARG OCI_VERSION=""

LABEL org.opencontainers.image.created="${OCI_CREATED}" \
org.opencontainers.image.description="Rust gateway for stateful agentic APIs backed by vLLM" \
org.opencontainers.image.licenses="Apache-2.0" \
org.opencontainers.image.revision="${OCI_REVISION}" \
org.opencontainers.image.source="${OCI_SOURCE}" \
org.opencontainers.image.title="agentic-api" \
org.opencontainers.image.url="${OCI_BUILD_URL}" \
org.opencontainers.image.version="${OCI_VERSION}" \
ai.vllm.build.commit="${OCI_REVISION}" \
ai.vllm.build.pipeline="${OCI_BUILD_PIPELINE}" \
ai.vllm.build.url="${OCI_BUILD_URL}" \
ai.vllm.image.tag="${OCI_VERSION}"

WORKDIR /var/lib/agentic-api
USER ${RUNTIME_UID}:${RUNTIME_GID}

ENV GATEWAY_HOST=0.0.0.0 \
GATEWAY_PORT=9000

EXPOSE 9000
ENTRYPOINT ["docker-entrypoint.sh"]
2 changes: 2 additions & 0 deletions crates/agentic-server-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ futures.workspace = true
indexmap.workspace = true
http.workspace = true
reqwest = { workspace = true, features = ["default-tls", "stream"] }
rmcp-reqwest.workspace = true
rmcp = { workspace = true, features = [
"client",
"reqwest",
Expand All @@ -33,6 +34,7 @@ serde_json.workspace = true
thiserror.workspace = true
tokio = { workspace = true, features = ["time"] }
tracing.workspace = true
url.workspace = true

sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "any", "migrate", "sqlite", "postgres"] }
chrono = { version = "0.4", features = ["serde"] }
Expand Down
Loading