From cc5f583e58b9fde9a5e79904e2d52f5097830350 Mon Sep 17 00:00:00 2001 From: Francisco Javier Arceo Date: Mon, 24 Aug 2026 08:21:10 -0400 Subject: [PATCH 01/18] feat: add Python distribution for agentic-api Add the lightweight agentic-api Python package and CLI for the 0.4.0 build-only release. The package supervises the Rust server and optional local vLLM process, exposes diagnostics and version commands, and ships the cross-platform wheel and release validation workflow while preserving the existing Rust CLI. Signed-off-by: Francisco Javier Arceo --- .github/workflows/python.yml | 101 +++++ .github/workflows/release-python.yml | 172 ++++++++ README.md | 34 +- crates/agentic-server/src/main.rs | 6 +- crates/agentic-server/tests/cli_test.rs | 36 ++ docs/guides/python-installation.md | 96 ++++ docs/index.md | 10 + pyproject.toml | 21 + python-build-constraints.txt | 4 + python/agentic_api.data/scripts/agentic-api | 5 + python/agentic_api/__init__.py | 4 + python/agentic_api/__main__.py | 11 + python/agentic_api/binary.py | 83 ++++ python/agentic_api/cli.py | 179 ++++++++ python/agentic_api/compatibility.py | 1 + python/agentic_api/diagnostics.py | 144 ++++++ python/agentic_api/launcher.py | 162 +++++++ python/agentic_api/process.py | 224 ++++++++++ python/agentic_api/version.py | 28 ++ scripts/check-python-wheel.sh | 292 ++++++++++++ scripts/validate-python-release-version.sh | 8 + tests/python/test_binary.py | 59 +++ tests/python/test_cli.py | 185 ++++++++ tests/python/test_diagnostics.py | 173 ++++++++ tests/python/test_docs_examples.py | 53 +++ tests/python/test_installed_cli.py | 171 +++++++ tests/python/test_launcher.py | 289 ++++++++++++ tests/python/test_metadata.py | 19 + tests/python/test_process.py | 466 ++++++++++++++++++++ tests/python/test_release_version.py | 86 ++++ tests/python/test_version.py | 31 ++ tests/python/test_wheel_check.py | 279 ++++++++++++ 32 files changed, 3430 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/python.yml create mode 100644 .github/workflows/release-python.yml create mode 100644 docs/guides/python-installation.md create mode 100644 pyproject.toml create mode 100644 python-build-constraints.txt create mode 100755 python/agentic_api.data/scripts/agentic-api create mode 100644 python/agentic_api/__init__.py create mode 100644 python/agentic_api/__main__.py create mode 100644 python/agentic_api/binary.py create mode 100644 python/agentic_api/cli.py create mode 100644 python/agentic_api/compatibility.py create mode 100644 python/agentic_api/diagnostics.py create mode 100644 python/agentic_api/launcher.py create mode 100644 python/agentic_api/process.py create mode 100644 python/agentic_api/version.py create mode 100755 scripts/check-python-wheel.sh create mode 100755 scripts/validate-python-release-version.sh create mode 100644 tests/python/test_binary.py create mode 100644 tests/python/test_cli.py create mode 100644 tests/python/test_diagnostics.py create mode 100644 tests/python/test_docs_examples.py create mode 100644 tests/python/test_installed_cli.py create mode 100644 tests/python/test_launcher.py create mode 100644 tests/python/test_metadata.py create mode 100644 tests/python/test_process.py create mode 100644 tests/python/test_release_version.py create mode 100644 tests/python/test_version.py create mode 100644 tests/python/test_wheel_check.py diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 00000000..3f1c4058 --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,101 @@ +name: Python + +run-name: Python wheel build and validation + +on: + pull_request: + paths: + - ".github/workflows/python.yml" + - ".github/workflows/release-python.yml" + - "Cargo.lock" + - "Cargo.toml" + - "crates/**" + - "pyproject.toml" + - "python-build-constraints.txt" + - "python/**" + - "scripts/check-python-wheel.sh" + - "scripts/validate-python-release-version.sh" + - "tests/python/**" + merge_group: + push: + branches: + - main + paths: + - ".github/workflows/python.yml" + - ".github/workflows/release-python.yml" + - "Cargo.lock" + - "Cargo.toml" + - "crates/**" + - "pyproject.toml" + - "python-build-constraints.txt" + - "python/**" + - "scripts/check-python-wheel.sh" + - "scripts/validate-python-release-version.sh" + - "tests/python/**" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/main' && github.run_id || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + python: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: | + pyproject.toml + python-build-constraints.txt + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: 1.98.0 + + - name: Cache cargo registry and build + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: cargo-python-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('Cargo.lock', 'python-build-constraints.txt') }} + restore-keys: | + cargo-python-${{ runner.os }}-${{ matrix.python-version }}- + cargo-python-${{ runner.os }}- + cargo-${{ runner.os }}- + + - name: Install uv + run: python -m pip install --constraint python-build-constraints.txt uv + + - name: Build and validate wheel + env: + AGENTIC_API_EXPECTED_VERSION: "0.4.0" + run: | + set -euo pipefail + uv venv --python "${{ matrix.python-version }}" .venv + uv pip install --python .venv/bin/python --constraint python-build-constraints.txt maturin pytest + wheel_dir="$RUNNER_TEMP/agentic-api-wheels-${{ matrix.python-version }}" + mkdir -p "$wheel_dir" + .venv/bin/python -m maturin build --release --locked --out "$wheel_dir" + wheel_path="$( + .venv/bin/python -c 'from pathlib import Path; import sys; matches = sorted(Path(sys.argv[1]).glob("agentic_api-0.4.0-*.whl")); assert len(matches) == 1, f"expected exactly one wheel, found {[path.name for path in matches]}"; print(matches[0])' "$wheel_dir" + )" + uv pip install --python .venv/bin/python "$wheel_path" + AGENTIC_API_TEST_WHEEL="$wheel_path" .venv/bin/python -m pytest tests/python -q + AGENTIC_API_CHECK_PYTHON=.venv/bin/python \ + AGENTIC_API_CHECK_SCRIPTS_DIR=.venv/bin \ + scripts/check-python-wheel.sh "$wheel_path" diff --git a/.github/workflows/release-python.yml b/.github/workflows/release-python.yml new file mode 100644 index 00000000..693d5bf9 --- /dev/null +++ b/.github/workflows/release-python.yml @@ -0,0 +1,172 @@ +name: Release Python + +run-name: Release Python wheels (${{ inputs.version }}) + +on: + workflow_dispatch: + inputs: + version: + description: "Build-only Python release version already merged into main" + required: true + type: string + default: "0.4.0" + +concurrency: + group: release-python-${{ inputs.version }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + build-wheels: + name: Build ${{ matrix.target }} wheel artifact + runs-on: ${{ matrix.runs-on }} + if: github.ref == 'refs/heads/main' + env: + AGENTIC_API_RELEASE_VERSION: ${{ inputs.version }} + strategy: + fail-fast: false + matrix: + include: + - runs-on: ubuntu-22.04 + os: linux + target: linux-x86_64 + wheel-tag: py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64 + - runs-on: macos-15-intel + os: macos + target: macos-x86_64 + wheel-tag: py3-none-macosx_10_12_x86_64 + deployment-target: "10.12" + - runs-on: macos-14 + os: macos + target: macos-arm64 + wheel-tag: py3-none-macosx_11_0_arm64 + deployment-target: "11.0" + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Verify build-only release gate version + run: scripts/validate-python-release-version.sh + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: | + pyproject.toml + python-build-constraints.txt + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: 1.98.0 + + - name: Cache cargo registry and build + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target/release-python/${{ matrix.target }} + key: cargo-release-python-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('Cargo.lock', 'python-build-constraints.txt') }} + restore-keys: | + cargo-release-python-${{ runner.os }}-${{ matrix.target }}- + cargo-${{ runner.os }}- + + - name: Install uv + run: python -m pip install --constraint python-build-constraints.txt uv + + - name: Verify version agreement and prepare artifact directory + id: prepare + run: | + set -euo pipefail + scripts/validate-python-release-version.sh + requested_version="$AGENTIC_API_RELEASE_VERSION" + wheel_dir="target/release-python/${{ matrix.target }}/wheels" + cargo_version="$(cargo metadata --format-version 1 --no-deps \ + | jq -r '.packages[] | select(.name == "agentic-server") | .version')" + core_version="$(cargo metadata --format-version 1 --no-deps \ + | jq -r '.packages[] | select(.name == "agentic-server-core") | .version')" + + if [ -z "$cargo_version" ] || [ "$cargo_version" = "null" ]; then + echo "::error::agentic-server package not found in workspace metadata" + exit 1 + fi + + if [ "$cargo_version" != "$core_version" ]; then + echo "::error::agentic-server ($cargo_version) and agentic-server-core ($core_version) versions differ" + exit 1 + fi + + if [ "$cargo_version" != "$requested_version" ]; then + echo "::error::workspace version $cargo_version does not match requested release version $requested_version" + exit 1 + fi + + rm -rf "$wheel_dir" + mkdir -p "$wheel_dir" + uv venv --python 3.12 .venv + uv pip install --python .venv/bin/python --constraint python-build-constraints.txt maturin pytest + echo "wheel_dir=$wheel_dir" >> "$GITHUB_OUTPUT" + + - name: Build pinned manylinux2014 x86_64 wheel + if: matrix.os == 'linux' + uses: PyO3/maturin-action@86b9d133d34bc1b40018696f782949dac11bd380 # v1.49.4 + with: + command: build + args: --release --locked --out ${{ steps.prepare.outputs.wheel_dir }} + container: quay.io/pypa/manylinux2014_x86_64@sha256:95440e0e72dd3a81dc8d2cf59a84d57af661456620f5bc821ff92048d0e54ff9 + manylinux: "2014" + maturin-version: v1.14.1 + rust-toolchain: 1.98.0 + target: x86_64-unknown-linux-gnu + + - name: Build macOS wheel + if: matrix.os == 'macos' + env: + CARGO_TARGET_DIR: target/release-python/${{ matrix.target }}/cargo + MACOSX_DEPLOYMENT_TARGET: ${{ matrix.deployment-target }} + run: >- + .venv/bin/python -m maturin build --release --locked + --out "${{ steps.prepare.outputs.wheel_dir }}" + + - name: Validate exact wheel artifact + id: build + run: | + set -euo pipefail + scripts/validate-python-release-version.sh + requested_version="$AGENTIC_API_RELEASE_VERSION" + wheel_dir="${{ steps.prepare.outputs.wheel_dir }}" + expected_wheel_tag="${{ matrix.wheel-tag }}" + wheel_path="$wheel_dir/agentic_api-${requested_version}-${expected_wheel_tag}.whl" + + if [ ! -f "$wheel_path" ]; then + echo "::error::expected wheel not found: $wheel_path" + find "$wheel_dir" -maxdepth 1 -type f -name '*.whl' -print + exit 1 + fi + + wheel_count="$(find "$wheel_dir" -maxdepth 1 -type f -name '*.whl' | wc -l | tr -d ' ')" + if [ "$wheel_count" != "1" ]; then + echo "::error::expected exactly one wheel in $wheel_dir; found $wheel_count" + exit 1 + fi + + uv pip install --python .venv/bin/python "$wheel_path" + AGENTIC_API_TEST_WHEEL="$wheel_path" .venv/bin/python -m pytest tests/python -q + AGENTIC_API_CHECK_PYTHON=.venv/bin/python \ + AGENTIC_API_CHECK_SCRIPTS_DIR=.venv/bin \ + AGENTIC_API_EXPECTED_VERSION="$requested_version" \ + AGENTIC_API_EXPECTED_WHEEL_TAG="$expected_wheel_tag" \ + scripts/check-python-wheel.sh "$wheel_path" + echo "wheel_path=$wheel_path" >> "$GITHUB_OUTPUT" + + - name: Upload wheel artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: agentic-api-${{ inputs.version }}-${{ matrix.target }} + path: ${{ steps.build.outputs.wheel_path }} + if-no-files-found: error diff --git a/README.md b/README.md index b574347d..e75f4958 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ ______________________________________________________________________ vLLM gives you state-of-the-art inference throughput. But real agentic applications need more than raw tokens: they need **conversation state, tool-call loops, and multi-turn orchestration**. Today, all of that complexity lives in your client code. -**Agentic API moves it server-side.** It is a Rust-native gateway that sits in front of vLLM and owns the stateful agentic APIs, starting with an OpenAI-compatible [Responses API](https://platform.openai.com/docs/api-reference/responses). Your application makes *one API call* and the server handles the rest: state hydration, tool execution, streaming, and continuation. +**Agentic API moves it server-side.** It is a Rust-native gateway that sits in front of vLLM and owns the stateful agentic APIs, starting with an OpenAI-compatible [Responses API](https://platform.openai.com/docs/api-reference/responses). vLLM is one supported backend, not part of the Agentic API product name. Your application makes *one API call* and the server handles the rest: state hydration, tool execution, streaming, and continuation. ```mermaid flowchart LR @@ -120,6 +120,38 @@ Use `AGENTIC_CODEX_BIN` or `AGENTIC_CLAUDE_BIN` to override harness binary disco `--quiet` for minimal lifecycle output. Use `--yolo` only in an externally isolated environment; it skips Claude permission checks and disables Codex approvals and sandboxing. +### Python distribution + +The `agentic-api` wheel packages the Rust gateway and a small Python launcher. Version 0.4.0 is a build-only release: +download the wheel artifact for your platform from the release workflow, then install that local file. It is not +published on PyPI. + +```bash +WHEEL_PATH=/absolute/path/to/agentic_api-0.4.0-PLATFORM.whl +uv pip install "$WHEEL_PATH" +agentic-api serve --vllm-base-url http://existing-vllm:8000 + +uv pip install "agentic-api[local] @ file://$WHEEL_PATH" +agentic-api serve --model MODEL_ID +``` + +The `[local]` extra is for supported Linux hosts where the launcher should manage a local vLLM process. + +#### Planned for 0.5.0 + +These public-index and `uvx` examples apply only after the PyPI publication gate for 0.5.0 passes: + +```bash +uv pip install agentic-api +uv pip install "agentic-api[local]" +uvx --from agentic-api agentic-api doctor +uvx --from agentic-api agentic-api serve --vllm-base-url http://existing-vllm:8000 +``` + +The Rust-native `agentic` CLI remains supported for `run codex`, `run claude`, `serve`, and `validate`. For the full +installation walkthrough, managed-vLLM passthrough examples, `doctor` output, and known-good model profiles, see +[Python installation and workflows](docs/guides/python-installation.md). + For Claude sessions, Agentic API always sets both `--effort medium` and `CLAUDE_CODE_EFFORT_LEVEL=medium`; the environment variable is intentional because Claude Code gives it precedence over the command-line effort flag. Qwen3.8-27B's vLLM chat template accepts `low`, `medium`, and `xhigh` reasoning effort values but not Claude Code's diff --git a/crates/agentic-server/src/main.rs b/crates/agentic-server/src/main.rs index 60a245ee..8b2931f0 100644 --- a/crates/agentic-server/src/main.rs +++ b/crates/agentic-server/src/main.rs @@ -73,7 +73,11 @@ fn oidc_config_from_values( } #[derive(Parser)] -#[command(name = "agentic-server", about = "Stateful API gateway for vLLM Responses API")] +#[command( + name = "agentic-server", + about = "Stateful API gateway for vLLM Responses API", + version +)] struct Cli { #[command(subcommand)] command: Option, diff --git a/crates/agentic-server/tests/cli_test.rs b/crates/agentic-server/tests/cli_test.rs index f9ef1165..8ded8b69 100644 --- a/crates/agentic-server/tests/cli_test.rs +++ b/crates/agentic-server/tests/cli_test.rs @@ -34,3 +34,39 @@ fn help_does_not_expose_database_url_credentials() { assert!(!stdout.contains(database_url)); assert!(!stdout.contains("super-secret")); } + +#[test] +fn agentic_server_reports_version() { + let output = Command::new(env!("CARGO_BIN_EXE_agentic-server")) + .arg("--version") + .output() + .expect("agentic-server version must run"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("stdout must be UTF-8"); + assert!(stdout.contains("agentic-server 0.4.0")); +} + +#[test] +fn packaged_agentic_cli_preserves_top_level_commands_and_harness_subcommands() { + let output = Command::new(env!("CARGO_BIN_EXE_agentic")) + .arg("--help") + .output() + .expect("agentic help must run"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("stdout must be UTF-8"); + assert!(stdout.contains("run")); + assert!(stdout.contains("serve")); + assert!(stdout.contains("validate")); + + let run_output = Command::new(env!("CARGO_BIN_EXE_agentic")) + .args(["run", "--help"]) + .output() + .expect("agentic run help must run"); + + assert!(run_output.status.success()); + let run_stdout = String::from_utf8(run_output.stdout).expect("stdout must be UTF-8"); + assert!(run_stdout.contains("codex")); + assert!(run_stdout.contains("claude")); +} diff --git a/docs/guides/python-installation.md b/docs/guides/python-installation.md new file mode 100644 index 00000000..70fc550a --- /dev/null +++ b/docs/guides/python-installation.md @@ -0,0 +1,96 @@ +# Python installation and workflows + +`agentic-api` is the Python distribution for the Rust-backed Agentic API gateway. vLLM is a supported inference +backend, not part of the Agentic API product name. Use the base wheel when you want a proxy-only install, and add the +`[local]` extra when you want the launcher to manage a local vLLM process. + +The Rust-native `agentic` CLI remains supported for `run codex`, `run claude`, `serve`, and `validate`. + +## Install the 0.4.0 artifact + +0.4.0 is a build-only release. It produces wheel artifacts for supported platforms but does not publish them to PyPI. +Download the wheel for your platform from the release workflow and use its absolute path below: + +```bash +WHEEL_PATH=/absolute/path/to/agentic_api-0.4.0-PLATFORM.whl +``` + +### Install the base package + +Proxy-only installs use the base wheel: + +```bash +uv pip install "$WHEEL_PATH" +agentic-api serve --vllm-base-url http://existing-vllm:8000 +``` + +Use this mode when an external vLLM server is already running and Agentic API should only proxy to it. + +### Install the local extra + +On supported Linux hosts, local installs add the tested vLLM runtime candidate. The `file://` reference must use the +absolute wheel path assigned above: + +```bash +uv pip install "agentic-api[local] @ file://$WHEEL_PATH" +agentic-api serve --model Qwen/Qwen3-30B-A3B-FP8 +``` + +The launcher still accepts arbitrary `--model` values. The `[local]` extra just supplies the tested vLLM dependency +and makes the managed-vLLM workflow available. + +Managed vLLM supports passthrough arguments after `--`: + +```bash +agentic-api serve --model Qwen/Qwen3-30B-A3B-FP8 -- \ + --dtype bfloat16 \ + --max-model-len=32768 +``` + +## Planned for 0.5.0 + +The following public-index installation and `uvx` commands are planned for 0.5.0. Use them only after the PyPI +publication gate has passed; they do not work for the unpublished 0.4.0 distribution: + +```bash +uv pip install agentic-api +uv pip install "agentic-api[local]" +uvx --from agentic-api agentic-api doctor +uvx --from agentic-api agentic-api serve --vllm-base-url http://existing-vllm:8000 +``` + +## Check the install + +`doctor` reports whether the packaged Rust executable is present, whether the tested local vLLM wheel is installed, and +whether the current mode is healthy. + +```bash +agentic-api doctor +agentic-api doctor --mode remote +agentic-api doctor --mode local +``` + +Use `--mode remote` when you only need the packaged Rust gateway checks. Use `--mode local` when you want to verify the +tested vLLM runtime and executable are available. + +## Rust-native CLI usage + +The Python package does not replace the Rust CLI. It complements it. + +```bash +agentic run codex --model MODEL_ID +agentic run claude --model SERVED_MODEL_ALIAS +``` + +## Known-good model profiles + +The matrix below is documentation data, not an allowlist. `agentic-api serve` still accepts arbitrary `--model` +values. The served alias column is only needed when Claude Code requires a slash-free model name, and the alias values +here are examples that should be revalidated on the target Linux GPU before promotion. + +| Model identifier | Required hardware class | Served alias for Claude Code | Tested launch arguments | +| --- | --- | --- | --- | +| `Qwen/Qwen3-30B-A3B-FP8` | Linux GPU host that can serve a 30B FP8 model | `qwen3-30b-a3b-fp8` | `vllm serve Qwen/Qwen3-30B-A3B-FP8 --reasoning-parser deepseek_r1 --port 5050` and `vllm serve Qwen/Qwen3-30B-A3B-FP8 --tool-call-parser hermes --enable-auto-tool-choice --port 5050` | + +Other documented model IDs already exercised in this repository include `Qwen/Qwen3.5-35B-A3B-FP8` and +`Qwen/Qwen3.8-27B-FP8`. Treat them as examples pending hardware revalidation rather than as a CLI allowlist. diff --git a/docs/index.md b/docs/index.md index bb3229af..a626bc3c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -33,6 +33,16 @@ Our first milestone is implementing the [Responses API](https://platform.openai. - **Background execution** — Fire-and-forget requests that continue processing server-side - **Compatibility tested** — Validated against the open Responses API compatibility test suite +## Python Distribution + +The `agentic-api` wheel packages the Rust gateway and a small Python launcher. Use the base package for proxy-only +installations, and the `[local]` extra when you want the launcher to manage a local vLLM process. + +- The 0.4.0 build-only release is installed from downloaded wheel artifacts and is not published on PyPI +- [Python installation and workflows](guides/python-installation.md) for current artifact installs, the future 0.5.0 public-index gate, `doctor`, and known-good model profiles +- The Rust-native `agentic` CLI remains supported for `serve`, `run codex`, `run claude`, and `validate` +- vLLM is a supported backend, not part of the Agentic API product name + ## Why Agentic API? vLLM is fast with state-of-the-art serving throughput, PagedAttention, continuous batching, and broad hardware support. But building agentic applications on top of it today requires significant client-side orchestration — managing conversation state, tool call loops, and multi-turn flows. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..37f8afa7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["maturin>=1.0,<2.0"] +build-backend = "maturin" + +[project] +name = "agentic-api" +dynamic = ["version"] +description = "Agentic API gateway for OpenAI-compatible inference servers" +requires-python = ">=3.10" + +[project.optional-dependencies] +local = ["vllm==0.11.0; platform_system == 'Linux'"] + +[tool.maturin] +manifest-path = "crates/agentic-server/Cargo.toml" +bindings = "bin" +module-name = "agentic_api" +python-source = "python" +python-packages = ["agentic_api"] +data = "python/agentic_api.data" +strip = true diff --git a/python-build-constraints.txt b/python-build-constraints.txt new file mode 100644 index 00000000..618df907 --- /dev/null +++ b/python-build-constraints.txt @@ -0,0 +1,4 @@ +# Python tooling used to build and validate release wheels. +maturin==1.14.1 +pytest==9.1.1 +uv==0.11.21 diff --git a/python/agentic_api.data/scripts/agentic-api b/python/agentic_api.data/scripts/agentic-api new file mode 100755 index 00000000..931b505b --- /dev/null +++ b/python/agentic_api.data/scripts/agentic-api @@ -0,0 +1,5 @@ +#!python +from agentic_api.cli import main + + +raise SystemExit(main()) diff --git a/python/agentic_api/__init__.py b/python/agentic_api/__init__.py new file mode 100644 index 00000000..521d3460 --- /dev/null +++ b/python/agentic_api/__init__.py @@ -0,0 +1,4 @@ +from importlib.metadata import version + + +__version__ = version("agentic-api") diff --git a/python/agentic_api/__main__.py b/python/agentic_api/__main__.py new file mode 100644 index 00000000..5f8b81f5 --- /dev/null +++ b/python/agentic_api/__main__.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from agentic_api import cli + + +def main() -> int: + return cli.main() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/python/agentic_api/binary.py b/python/agentic_api/binary.py new file mode 100644 index 00000000..6e7d4b1a --- /dev/null +++ b/python/agentic_api/binary.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import sysconfig +from pathlib import Path + + +REMEDIATION_MESSAGE = "Reinstall agentic-api for this platform" + + +class PackagedBinaryNotFoundError(FileNotFoundError): + """Raised when a packaged executable cannot be located.""" + + +class PackagedBinaryVersionError(RuntimeError): + """Raised when a packaged executable cannot report its version.""" + + +def find_packaged_binary(name: str) -> Path: + try: + return find_active_environment_executable(name) + except FileNotFoundError: + pass + raise PackagedBinaryNotFoundError(f"{name} not found; {REMEDIATION_MESSAGE}") + + +def find_active_environment_executable(name: str) -> Path: + for candidate in _candidate_paths(name): + if _is_executable_file(candidate): + return candidate + raise FileNotFoundError(f"{name} executable not found in the active environment") + + +def read_binary_version(path: Path) -> str: + try: + completed = subprocess.run( + [str(path), "--version"], + check=True, + capture_output=True, + text=True, + ) + except OSError as error: # pragma: no cover - exercised via unit tests. + raise PackagedBinaryVersionError(f"unable to launch {path}: {error.strerror or error}") from error + except subprocess.CalledProcessError as error: + raise PackagedBinaryVersionError( + f"{path} exited with status {error.returncode} while reporting its version" + ) from error + + output = (completed.stdout or completed.stderr).strip() + if not output: + raise PackagedBinaryVersionError(f"{path} did not report a version") + return output.splitlines()[0].strip() + + +def _candidate_paths(name: str) -> list[Path]: + candidates: list[Path] = [] + + scripts_dir = sysconfig.get_path("scripts") + if scripts_dir: + candidates.append(Path(scripts_dir) / name) + + candidates.append(Path(sys.executable).resolve().parent / name) + + which_path = shutil.which(name) + if which_path: + candidates.append(Path(which_path)) + + unique_candidates: list[Path] = [] + seen: set[Path] = set() + for candidate in candidates: + resolved = candidate.resolve(strict=False) + if resolved in seen: + continue + seen.add(resolved) + unique_candidates.append(candidate) + return unique_candidates + + +def _is_executable_file(path: Path) -> bool: + return path.is_file() and os.access(path, os.X_OK) diff --git a/python/agentic_api/cli.py b/python/agentic_api/cli.py new file mode 100644 index 00000000..10d5594b --- /dev/null +++ b/python/agentic_api/cli.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import argparse +import math +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Sequence +from urllib.parse import urlparse + +from agentic_api.diagnostics import doctor +from agentic_api.version import version_report + + +DEFAULT_HOST = "0.0.0.0" +DEFAULT_PORT = 9000 +DEFAULT_STARTUP_TIMEOUT_S = 600.0 +DEFAULT_SHUTDOWN_TIMEOUT_S = 10.0 +DEFAULT_VLLM_PORT = 8000 +DEFAULT_GATEWAY_API_KEY_ENV = "OPENAI_API_KEY" +DEFAULT_VLLM_API_KEY_ENV = "AGENTIC_VLLM_API_KEY" +RESERVED_VLLM_FLAGS = {"--host", "--port", "--api-key"} +INCOMPATIBLE_VLLM_FLAGS = {"--uds"} +MAX_TIMEOUT_S = 86_400.0 + + +@dataclass(frozen=True) +class ServeOptions: + mode: str + model: str | None + vllm_base_url: str | None + host: str + port: int + startup_timeout_s: float + shutdown_timeout_s: float + vllm_port: int + gateway_api_key_env: str + vllm_api_key_env: str + vllm_args: list[str] + + +class _AgenticArgumentParser(argparse.ArgumentParser): + def parse_args(self, args: Sequence[str] | None = None, namespace: argparse.Namespace | None = None) -> argparse.Namespace: + parsed = super().parse_args(args=args, namespace=namespace) + command = getattr(parsed, "command", None) + + if command == "serve": + parsed.options = _build_serve_options(self, parsed) + + return parsed + + +def build_parser() -> argparse.ArgumentParser: + parser = _AgenticArgumentParser(prog="agentic-api", description="Python launcher for packaged Agentic API binaries") + subparsers = parser.add_subparsers(dest="command", required=True) + + serve_parser = subparsers.add_parser("serve", help="Launch Agentic API in local or remote mode") + serve_parser.add_argument("--model") + serve_parser.add_argument("--vllm-base-url") + serve_parser.add_argument("--host", default=DEFAULT_HOST) + serve_parser.add_argument("--port", type=_tcp_port, default=DEFAULT_PORT) + serve_parser.add_argument("--startup-timeout-s", type=_bounded_timeout, default=DEFAULT_STARTUP_TIMEOUT_S) + serve_parser.add_argument("--shutdown-timeout-s", type=_bounded_timeout, default=DEFAULT_SHUTDOWN_TIMEOUT_S) + serve_parser.add_argument("--vllm-port", type=_tcp_port, default=DEFAULT_VLLM_PORT) + serve_parser.add_argument("--gateway-api-key-env", default=DEFAULT_GATEWAY_API_KEY_ENV) + serve_parser.add_argument("--vllm-api-key-env", default=DEFAULT_VLLM_API_KEY_ENV) + serve_parser.add_argument("vllm_args", nargs=argparse.REMAINDER) + + doctor_parser = subparsers.add_parser("doctor", help="Report packaged binary and compatibility diagnostics") + doctor_parser.add_argument("--mode", choices=("local", "remote")) + + subparsers.add_parser("version", help="Print package and packaged binary versions") + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = build_parser() + try: + namespace = parser.parse_args(argv) + except SystemExit as error: + code = error.code + return code if isinstance(code, int) else 1 + + if namespace.command == "version": + print(version_report()) + return 0 + + if namespace.command == "doctor": + return doctor(namespace.mode) + + if namespace.command == "serve": + try: + from agentic_api.launcher import run_serve + except ModuleNotFoundError: + print("agentic-api serve is not implemented in this build yet.", file=sys.stderr) + return 1 + + return run_serve(namespace.options) + + parser.error(f"unknown command: {namespace.command}") + return 1 + + +def _build_serve_options(parser: argparse.ArgumentParser, namespace: argparse.Namespace) -> ServeOptions: + source_count = int(bool(namespace.model)) + int(bool(namespace.vllm_base_url)) + if source_count != 1: + parser.error("exactly one of --model or --vllm-base-url is required") + + vllm_args = _normalize_vllm_args(namespace.vllm_args) + reserved_flag = _reserved_vllm_flag(vllm_args) + if reserved_flag is not None: + parser.error(f"{reserved_flag} is reserved for the launcher and cannot be forwarded after --") + + vllm_base_url = None + mode = "local" + if namespace.vllm_base_url is not None: + vllm_base_url = _normalize_base_url(parser, namespace.vllm_base_url) + mode = "remote" + + return ServeOptions( + mode=mode, + model=namespace.model, + vllm_base_url=vllm_base_url, + host=namespace.host, + port=namespace.port, + startup_timeout_s=namespace.startup_timeout_s, + shutdown_timeout_s=namespace.shutdown_timeout_s, + vllm_port=namespace.vllm_port, + gateway_api_key_env=namespace.gateway_api_key_env, + vllm_api_key_env=namespace.vllm_api_key_env, + vllm_args=vllm_args, + ) + + +def _normalize_vllm_args(values: Sequence[str]) -> list[str]: + if values and values[0] == "--": + return list(values[1:]) + return list(values) + + +def _reserved_vllm_flag(values: Sequence[str]) -> str | None: + for value in values: + if not value.startswith("--"): + continue + option_name = value.partition("=")[0].replace("_", "-") + if option_name in RESERVED_VLLM_FLAGS or option_name in INCOMPATIBLE_VLLM_FLAGS: + return option_name + return None + + +def _tcp_port(value: str) -> int: + try: + port = int(value) + except ValueError as error: + raise argparse.ArgumentTypeError("must be between 1 and 65535") from error + if not 1 <= port <= 65_535: + raise argparse.ArgumentTypeError("must be between 1 and 65535") + return port + + +def _bounded_timeout(value: str) -> float: + try: + timeout = float(value) + except ValueError as error: + raise argparse.ArgumentTypeError( + f"must be finite and greater than 0, up to {MAX_TIMEOUT_S:g} seconds" + ) from error + if not math.isfinite(timeout) or not 0 < timeout <= MAX_TIMEOUT_S: + raise argparse.ArgumentTypeError(f"must be finite and greater than 0, up to {MAX_TIMEOUT_S:g} seconds") + return timeout + + +def _normalize_base_url(parser: argparse.ArgumentParser, value: str) -> str: + parsed = urlparse(value) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + parser.error("--vllm-base-url must be an http:// or https:// base URL") + if parsed.query or parsed.fragment: + parser.error("--vllm-base-url must not include a query string or fragment") + return value.rstrip("/") diff --git a/python/agentic_api/compatibility.py b/python/agentic_api/compatibility.py new file mode 100644 index 00000000..3051a9b6 --- /dev/null +++ b/python/agentic_api/compatibility.py @@ -0,0 +1 @@ +SUPPORTED_VLLM_VERSION = "0.11.0" diff --git a/python/agentic_api/diagnostics.py b/python/agentic_api/diagnostics.py new file mode 100644 index 00000000..52cc2245 --- /dev/null +++ b/python/agentic_api/diagnostics.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import os +import platform +from dataclasses import dataclass +from importlib.metadata import PackageNotFoundError, version as metadata_version +from pathlib import Path + +from agentic_api.binary import ( + PackagedBinaryNotFoundError, + PackagedBinaryVersionError, + find_active_environment_executable, + find_packaged_binary, + read_binary_version, +) +from agentic_api.compatibility import SUPPORTED_VLLM_VERSION + + +@dataclass(frozen=True) +class DoctorReport: + python_version: str + platform_summary: str + package_version: str + rust_binary_path: str + rust_binary_executable: bool + rust_binary_version: str + supported_vllm_version: str + installed_vllm_version: str + vllm_executable_path: str + local_ok: bool + remote_ok: bool + local_message: str + remote_message: str + + +def doctor(mode: str | None) -> int: + report = collect_doctor_report() + print(render_doctor_report(report, mode)) + + if mode == "local": + return 0 if report.local_ok else 1 + if mode == "remote": + return 0 if report.remote_ok else 1 + return 0 if report.local_ok and report.remote_ok else 1 + + +def collect_doctor_report() -> DoctorReport: + rust_binary_path, rust_binary_executable, rust_binary_version = _rust_binary_details() + package_version = _package_version("agentic-api") + installed_vllm_version = _package_version("vllm") + try: + vllm_executable_path = str(find_active_environment_executable("vllm")) + except FileNotFoundError: + vllm_executable_path = "not found" + local_ok, local_message = _local_health(installed_vllm_version, vllm_executable_path) + + return DoctorReport( + python_version=platform.python_version(), + platform_summary=f"{platform.system()} {platform.machine()}", + package_version=package_version, + rust_binary_path=rust_binary_path, + rust_binary_executable=rust_binary_executable, + rust_binary_version=rust_binary_version, + supported_vllm_version=SUPPORTED_VLLM_VERSION, + installed_vllm_version=installed_vllm_version, + vllm_executable_path=vllm_executable_path, + local_ok=local_ok, + remote_ok=rust_binary_executable and not rust_binary_version.startswith("error:"), + local_message=local_message, + remote_message=_remote_health_message(rust_binary_executable, rust_binary_version), + ) + + +def render_doctor_report(report: DoctorReport, mode: str | None) -> str: + local_health = "ok" if report.local_ok else "unavailable" + remote_health = "ok" if report.remote_ok else "unavailable" + + lines = [ + f"Selected mode: {mode or 'all'}", + f"Python version: {report.python_version}", + f"Platform: {report.platform_summary}", + f"agentic-api version: {report.package_version}", + f"Rust binary path: {report.rust_binary_path}", + f"Rust binary executable: {'yes' if report.rust_binary_executable else 'no'}", + f"Rust binary version: {report.rust_binary_version}", + f"Supported vLLM version: {report.supported_vllm_version}", + f"Installed vLLM version: {report.installed_vllm_version}", + f"vLLM executable path: {report.vllm_executable_path}", + f"Local mode health: {local_health}", + f"Local mode details: {report.local_message}", + f"Remote mode health: {remote_health}", + f"Remote mode details: {report.remote_message}", + ] + return "\n".join(lines) + + +def _package_version(name: str) -> str: + try: + return metadata_version(name) + except PackageNotFoundError: + return "not installed" + + +def _rust_binary_details() -> tuple[str, bool, str]: + try: + path = find_packaged_binary("agentic-server") + except PackagedBinaryNotFoundError as error: + return ("not found", False, f"error: {error}") + + executable = path.is_file() and os.access(path, os.X_OK) + try: + version = read_binary_version(path) + except PackagedBinaryVersionError as error: + version = f"error: {error}" + return (str(path), executable, version) + + +def _local_health(installed_vllm_version: str, vllm_executable_path: str) -> tuple[bool, str]: + install_hint = ( + "Install the 0.4.0 wheel artifact with its local extra: " + '`uv pip install "agentic-api[local] @ file:///path/to/agentic_api-0.4.0-PLATFORM.whl"`.' + ) + if installed_vllm_version == "not installed": + return (False, install_hint) + if installed_vllm_version != SUPPORTED_VLLM_VERSION: + return ( + False, + f"Installed vLLM does not match the tested version {SUPPORTED_VLLM_VERSION}. {install_hint}", + ) + if vllm_executable_path == "not found": + return ( + False, + f"The installed vLLM package does not provide a `vllm` executable in the active Python environment. " + f"{install_hint}", + ) + return (True, "The tested local vLLM package and executable are available.") + + +def _remote_health_message(rust_binary_executable: bool, rust_binary_version: str) -> str: + if not rust_binary_executable: + return "The packaged Rust gateway executable is missing or not executable." + if rust_binary_version.startswith("error:"): + return "The packaged Rust gateway executable could not report its version." + return "The packaged Rust gateway executable is available; remote mode does not require vLLM." diff --git a/python/agentic_api/launcher.py b/python/agentic_api/launcher.py new file mode 100644 index 00000000..072ef1e0 --- /dev/null +++ b/python/agentic_api/launcher.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import os +import secrets +import signal +import sys +from collections.abc import Sequence +from importlib.metadata import PackageNotFoundError, version + +from agentic_api.binary import ( + PackagedBinaryNotFoundError, + find_active_environment_executable, + find_packaged_binary, +) +from agentic_api.cli import ServeOptions +from agentic_api.compatibility import SUPPORTED_VLLM_VERSION +from agentic_api.process import ChildResult, ProcessSupervisor, ShutdownRequested, wait_for_vllm_ready + + +READY_INTERVAL_S = 2.0 + + +def run_serve(options: ServeOptions) -> int: + supervisor = ProcessSupervisor() + signal_exit_code: int | None = None + previous_handlers: list[tuple[int, object]] = [] + + def begin_shutdown(signum: int, _frame: object) -> None: + nonlocal signal_exit_code + if signal_exit_code is None: + signal_exit_code = _signal_exit_code(signum) + supervisor.request_shutdown(signum) + + for signum in (signal.SIGINT, signal.SIGTERM): + previous_handlers.append((signum, signal.signal(signum, begin_shutdown))) + + try: + if options.mode == "local": + result = _run_local_mode(supervisor, options) + elif options.mode == "remote": + result = _run_remote_mode(supervisor, options) + else: + raise ValueError(f"unsupported serve mode: {options.mode}") + + if signal_exit_code is not None: + return signal_exit_code + return _normalize_exit_code(result) + except ShutdownRequested: + if signal_exit_code is not None: + return signal_exit_code + raise + except KeyboardInterrupt: + signal_exit_code = _signal_exit_code(signal.SIGINT) + supervisor.request_shutdown(signal.SIGINT) + return signal_exit_code + except (FileNotFoundError, PackagedBinaryNotFoundError, RuntimeError, ValueError) as error: + if signal_exit_code is not None: + return signal_exit_code + print(str(error), file=sys.stderr) + return 1 + finally: + supervisor.terminate_all(options.shutdown_timeout_s) + for signum, previous in previous_handlers: + signal.signal(signum, previous) + + +def _run_local_mode(supervisor: ProcessSupervisor, options: ServeOptions) -> ChildResult: + if options.model is None: + raise ValueError("--model is required in local mode") + + vllm_path = find_active_environment_executable("vllm") + installed_version = _installed_vllm_version() + if installed_version != SUPPORTED_VLLM_VERSION: + raise RuntimeError( + f"agentic-api local mode requires vllm=={SUPPORTED_VLLM_VERSION}; found {installed_version}" + ) + + vllm_api_key = os.environ.get(options.vllm_api_key_env) or secrets.token_urlsafe(24) + vllm_url = f"http://127.0.0.1:{options.vllm_port}" + + vllm_process = supervisor.start( + [ + str(vllm_path), + "serve", + options.model, + *options.vllm_args, + "--host", + "127.0.0.1", + "--port", + str(options.vllm_port), + "--api-key", + vllm_api_key, + ], + os.environ.copy(), + ) + wait_for_vllm_ready( + base_url=vllm_url, + api_key=vllm_api_key, + process=vllm_process, + timeout=options.startup_timeout_s, + interval=READY_INTERVAL_S, + shutdown_requested=supervisor.shutdown_requested, + ) + + supervisor.start( + _rust_command(options, vllm_url), + _rust_environment(options.gateway_api_key_env, vllm_api_key), + ) + return supervisor.wait_for_failure() + + +def _run_remote_mode(supervisor: ProcessSupervisor, options: ServeOptions) -> ChildResult: + if options.vllm_base_url is None: + raise ValueError("--vllm-base-url is required in remote mode") + + supervisor.start( + _rust_command(options, options.vllm_base_url), + _rust_environment(options.gateway_api_key_env, None), + ) + return supervisor.wait_for_failure() + + +def _rust_command(options: ServeOptions, upstream_base_url: str) -> list[str]: + binary = find_packaged_binary("agentic-server") + return [ + str(binary), + "--llm-api-base", + upstream_base_url, + "--gateway-host", + options.host, + "--gateway-port", + str(options.port), + ] + + +def _rust_environment(gateway_api_key_env: str, api_key_override: str | None) -> dict[str, str]: + env = os.environ.copy() + gateway_api_key = api_key_override if api_key_override is not None else os.environ.get(gateway_api_key_env) + if gateway_api_key is not None: + env["OPENAI_API_KEY"] = gateway_api_key + elif gateway_api_key_env != "OPENAI_API_KEY": + env.pop("OPENAI_API_KEY", None) + return env + + +def _installed_vllm_version() -> str: + try: + return version("vllm") + except PackageNotFoundError as error: + raise RuntimeError( + f"agentic-api local mode requires vllm=={SUPPORTED_VLLM_VERSION}; install the [local] extra first" + ) from error + + +def _normalize_exit_code(result: ChildResult) -> int: + if result.returncode >= 0: + return result.returncode + return _signal_exit_code(-result.returncode) + + +def _signal_exit_code(signum: int) -> int: + return 128 + int(signum) diff --git a/python/agentic_api/process.py b/python/agentic_api/process.py new file mode 100644 index 00000000..77616e69 --- /dev/null +++ b/python/agentic_api/process.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +import os +import signal +import socket +import subprocess +import time +import urllib.error +import urllib.request +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from threading import Lock + + +POLL_INTERVAL_S = 0.05 +READY_REQUEST_TIMEOUT_S = 0.1 + + +class ShutdownRequested(RuntimeError): + """Raised when launcher shutdown has been requested.""" + + +@dataclass(frozen=True) +class ChildResult: + name: str + command: tuple[str, ...] + returncode: int + + +@dataclass(frozen=True) +class _ManagedChild: + name: str + command: tuple[str, ...] + process: subprocess.Popen[str] + process_group_id: int | None + + +class ProcessSupervisor: + def __init__(self) -> None: + self._children: list[_ManagedChild] = [] + self._lock = Lock() + self._shutdown_requested = False + self._shutdown_signal: int | None = None + + def request_shutdown(self, signal_number: int | None = None) -> None: + self._shutdown_requested = True + if signal_number is not None and self._shutdown_signal is None: + self._shutdown_signal = signal_number + + def shutdown_requested(self) -> bool: + return self._shutdown_requested + + def shutdown_signal(self) -> int | None: + return self._shutdown_signal + + def start(self, command: Sequence[str], env: Mapping[str, str]) -> subprocess.Popen[str]: + command_list = [str(part) for part in command] + popen_kwargs: dict[str, object] = { + "env": dict(env), + "shell": False, + } + if os.name == "posix": + popen_kwargs["start_new_session"] = True + + process = subprocess.Popen(command_list, **popen_kwargs) + child = _ManagedChild( + name=Path(command_list[0]).name or command_list[0], + command=tuple(command_list), + process=process, + process_group_id=process.pid if os.name == "posix" else None, + ) + with self._lock: + self._children.append(child) + shutdown_requested = self._shutdown_requested + if shutdown_requested: + self._terminate_child(child) + return process + + def terminate_all(self, timeout: float) -> None: + self.request_shutdown() + deadline = time.monotonic() + max(timeout, 0.0) + seen_children: set[int] = set() + + while True: + with self._lock: + children = [child for child in self._children if id(child) not in seen_children] + + if not children: + return + + for child in children: + seen_children.add(id(child)) + self._terminate_child(child) + + pending: list[_ManagedChild] = [] + for child in children: + self._wait_for_child_target(child, deadline) + if self._child_target_exists(child): + pending.append(child) + + for child in pending: + self._kill_child(child) + + force_kill_deadline = time.monotonic() + 1.0 + for child in pending: + self._wait_for_child_target(child, force_kill_deadline) + + def wait_for_failure(self) -> ChildResult: + while True: + if self._shutdown_requested: + raise ShutdownRequested() + + with self._lock: + children = list(self._children) + + if not children: + raise RuntimeError("no managed child processes") + + for child in children: + returncode = child.process.poll() + if returncode is not None: + return ChildResult(name=child.name, command=child.command, returncode=returncode) + + time.sleep(POLL_INTERVAL_S) + + def _terminate_child(self, child: _ManagedChild) -> None: + if child.process_group_id is not None: + try: + os.killpg(child.process_group_id, signal.SIGTERM) + except ProcessLookupError: + pass + return + if child.process.poll() is None: + child.process.terminate() + + def _kill_child(self, child: _ManagedChild) -> None: + if child.process_group_id is not None: + try: + os.killpg(child.process_group_id, signal.SIGKILL) + except ProcessLookupError: + pass + return + if child.process.poll() is None: + child.process.kill() + + def _wait_for_child_target(self, child: _ManagedChild, deadline: float) -> None: + while True: + child.process.poll() + if not self._child_target_exists(child): + break + remaining = deadline - time.monotonic() + if remaining <= 0: + break + time.sleep(min(POLL_INTERVAL_S, remaining)) + child.process.poll() + + def _child_target_exists(self, child: _ManagedChild) -> bool: + if child.process_group_id is None: + return child.process.poll() is None + try: + os.killpg(child.process_group_id, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def wait_for_vllm_ready( + base_url: str, + api_key: str | None, + process: subprocess.Popen[str], + timeout: float, + interval: float, + shutdown_requested: Callable[[], bool] | None = None, +) -> None: + ready_url = f"{base_url.rstrip('/')}/v1/models" + deadline = time.monotonic() + max(timeout, 0.0) + headers = {"Accept": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + last_error: str | None = None + while True: + if shutdown_requested is not None and shutdown_requested(): + raise ShutdownRequested() + + returncode = process.poll() + if returncode is not None: + raise RuntimeError(f"vLLM exited with status {returncode} before becoming ready") + + remaining = deadline - time.monotonic() + if remaining <= 0: + break + + request = urllib.request.Request(ready_url, headers=headers) + request_timeout = min(READY_REQUEST_TIMEOUT_S, max(remaining, 0.01)) + try: + with urllib.request.urlopen(request, timeout=request_timeout) as response: + if 200 <= response.status < 300: + return + last_error = f"HTTP {response.status}" + except urllib.error.HTTPError as error: + last_error = f"HTTP {error.code}" + except (TimeoutError, socket.timeout): + last_error = "request timeout" + except urllib.error.URLError: + last_error = "connection failure" + + if shutdown_requested is not None and shutdown_requested(): + raise ShutdownRequested() + + returncode = process.poll() + if returncode is not None: + raise RuntimeError(f"vLLM exited with status {returncode} before becoming ready") + + remaining = deadline - time.monotonic() + if remaining <= 0: + break + time.sleep(min(max(interval, 0.0), remaining)) + + detail = f" ({last_error})" if last_error else "" + raise TimeoutError(f"timed out waiting for vLLM readiness{detail}") diff --git a/python/agentic_api/version.py b/python/agentic_api/version.py new file mode 100644 index 00000000..a62c97f2 --- /dev/null +++ b/python/agentic_api/version.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from importlib.metadata import PackageNotFoundError, version as metadata_version + +from agentic_api import __version__ +from agentic_api.binary import find_packaged_binary, read_binary_version +from agentic_api.compatibility import SUPPORTED_VLLM_VERSION + + +def version_report() -> str: + binary_path = find_packaged_binary("agentic-server") + rust_version = read_binary_version(binary_path) + installed_vllm_version = _installed_vllm_version() + return "\n".join( + ( + f"agentic-api version: {__version__}", + f"Rust binary version: {rust_version}", + f"Supported vLLM version: {SUPPORTED_VLLM_VERSION}", + f"Installed vLLM version: {installed_vllm_version}", + ) + ) + + +def _installed_vllm_version() -> str: + try: + return metadata_version("vllm") + except PackageNotFoundError: + return "not installed" diff --git a/scripts/check-python-wheel.sh b/scripts/check-python-wheel.sh new file mode 100755 index 00000000..9ed805df --- /dev/null +++ b/scripts/check-python-wheel.sh @@ -0,0 +1,292 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "usage: scripts/check-python-wheel.sh " >&2 + exit 1 +fi + +wheel_path="$1" +check_python="${AGENTIC_API_CHECK_PYTHON:-python}" +expected_version="${AGENTIC_API_EXPECTED_VERSION:-0.4.0}" +expected_wheel_tag="${AGENTIC_API_EXPECTED_WHEEL_TAG:-}" +scripts_dir="${AGENTIC_API_CHECK_SCRIPTS_DIR:-}" +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +repo_root="$(cd -- "$script_dir/.." && pwd -P)" +cargo_manifest_path="$repo_root/Cargo.toml" + +if [ ! -f "$wheel_path" ]; then + echo "wheel file not found: $wheel_path" >&2 + exit 1 +fi + +"$check_python" - "$wheel_path" "$expected_version" "$scripts_dir" "$cargo_manifest_path" "$expected_wheel_tag" <<'PY' +from __future__ import annotations + +import importlib +import json +import os +import stat +import subprocess +import sys +import sysconfig +import zipfile +from email.parser import Parser +from importlib.metadata import PackageNotFoundError, distribution +from pathlib import Path + + +FORBIDDEN_SUBSTRINGS = ( + "/vllm/", + "/torch/", + "libtorch", + "/transformers/", + "/nvidia/", + "/nvidia/cublas/", + "libcublas.so", + "libcublaslt.so", + "libcuda.so", + "libcudart.so", + "libcufft.so", + "libcurand.so", + "libcusolver.so", + "libcusparse.so", + "libnccl.so", + "libnvrtc", + "cuda", + "cudnn", + "rocm", + "/hip/", + "libamdhip64.so", + "libhipblas.so", + "librocblas.so", + "libhsa-runtime64.so", + "librocm", +) + + +def fail(message: str) -> None: + print(message, file=sys.stderr) + raise SystemExit(1) + + +def ensure(condition: bool, message: str) -> None: + if not condition: + fail(message) + + +def normalize(path: str) -> str: + return path.replace("\\", "/").lower() + + +wheel_path = Path(sys.argv[1]) +expected_version = sys.argv[2] +scripts_dir_override = sys.argv[3] +cargo_manifest_path = Path(sys.argv[4]) +expected_wheel_tag = sys.argv[5] +cargo_metadata_path_override = os.environ.get("AGENTIC_API_CHECK_CARGO_METADATA_JSON") + +if expected_wheel_tag: + expected_wheel_name = f"agentic_api-{expected_version}-{expected_wheel_tag}.whl" + ensure( + wheel_path.name == expected_wheel_name, + f"wheel tag must be exactly {expected_wheel_tag}; found {wheel_path.name}", + ) + + +def load_cargo_metadata() -> dict[str, object]: + if cargo_metadata_path_override: + try: + return json.loads(Path(cargo_metadata_path_override).read_text(encoding="utf-8")) + except OSError as error: + fail(f"unable to read cargo metadata fixture {cargo_metadata_path_override}: {error}") + except json.JSONDecodeError as error: + fail(f"invalid cargo metadata fixture {cargo_metadata_path_override}: {error}") + + try: + completed = subprocess.run( + [ + "cargo", + "metadata", + "--format-version", + "1", + "--no-deps", + "--manifest-path", + str(cargo_manifest_path), + ], + check=True, + capture_output=True, + text=True, + ) + except OSError as error: + fail( + "cargo metadata check: unable to launch " + f"cargo metadata --format-version 1 --no-deps --manifest-path {cargo_manifest_path}: {error}" + ) + except subprocess.CalledProcessError as error: + output = (error.stdout or error.stderr or "").strip() + if output: + fail(f"cargo metadata check failed: {output}") + fail(f"cargo metadata check exited with status {error.returncode}") + + try: + return json.loads(completed.stdout) + except json.JSONDecodeError as error: + fail(f"cargo metadata produced invalid JSON: {error}") + + +def check_workspace_package_versions() -> None: + metadata = load_cargo_metadata() + packages = metadata.get("packages") + workspace_members = metadata.get("workspace_members") + + ensure(isinstance(packages, list), "cargo metadata packages must be a list") + ensure(isinstance(workspace_members, list), "cargo metadata workspace_members must be a list") + + workspace_member_ids = {member for member in workspace_members if isinstance(member, str)} + workspace_packages = [ + package + for package in packages + if isinstance(package, dict) and package.get("id") in workspace_member_ids + ] + ensure(workspace_packages, "cargo metadata returned no workspace packages") + + for package in workspace_packages: + name = package.get("name") + version = package.get("version") + ensure(isinstance(name, str), f"workspace package has invalid name: {package!r}") + ensure(isinstance(version, str), f"workspace package {name!r} has invalid version: {package!r}") + ensure( + version == expected_version, + f"workspace package {name} version must be {expected_version}; found {version!r}", + ) + + +check_workspace_package_versions() + +with zipfile.ZipFile(wheel_path) as archive: + names = archive.namelist() + normalized_names = [normalize(name) for name in names] + + ensure( + any(name == "agentic_api/__init__.py" or name.startswith("agentic_api/") for name in normalized_names), + "wheel missing agentic_api package", + ) + + metadata_path = next((name for name in names if name.endswith(".dist-info/METADATA")), None) + ensure(metadata_path is not None, "wheel missing dist-info metadata") + metadata = Parser().parsestr(archive.read(metadata_path).decode("utf-8")) + ensure(metadata.get("Name") == "agentic-api", "wheel metadata Name must be agentic-api") + ensure( + metadata.get("Version") == expected_version, + f"wheel metadata Version must be {expected_version}; found {metadata.get('Version')!r}", + ) + + entry_points_text = "" + entry_points_path = next((name for name in names if name.endswith(".dist-info/entry_points.txt")), None) + if entry_points_path is not None: + entry_points_text = archive.read(entry_points_path).decode("utf-8") + + def has_packaged_script(script_name: str) -> bool: + return any( + f"/scripts/{script_name}" in f"/{name}" and ".data/" in name + for name in normalized_names + ) + + has_agentic_api_console = has_packaged_script("agentic-api") or ( + "agentic-api" in entry_points_text + ) + ensure(has_agentic_api_console, "wheel missing agentic-api console script") + + for binary_name in ("agentic", "agentic-server"): + ensure( + has_packaged_script(binary_name), + f"wheel missing packaged executable: {binary_name}", + ) + + forbidden_entry = next( + ( + original_name + for original_name, lowered_name in zip(names, normalized_names, strict=True) + if any(marker in f"/{lowered_name}" for marker in FORBIDDEN_SUBSTRINGS) + ), + None, + ) + ensure(forbidden_entry is None, f"forbidden wheel payload: {forbidden_entry}") + +try: + installed_distribution = distribution("agentic-api") +except PackageNotFoundError as error: + fail(f"installed distribution not found: {error}") + +ensure( + installed_distribution.version == expected_version, + f"installed distribution version must be {expected_version}; found {installed_distribution.version!r}", +) +ensure( + installed_distribution.metadata.get("Version") == expected_version, + ( + "installed distribution metadata Version must be " + f"{expected_version}; found {installed_distribution.metadata.get('Version')!r}" + ), +) + +agentic_api = importlib.import_module("agentic_api") +installed_module_version = getattr(agentic_api, "__version__", None) +ensure( + installed_module_version == expected_version, + f"agentic_api.__version__ must be {expected_version}; found {installed_module_version!r}", +) + +scripts_dir = Path(scripts_dir_override) if scripts_dir_override else Path(sysconfig.get_path("scripts") or "") +ensure(scripts_dir.is_dir(), f"scripts directory not found: {scripts_dir}") + +for command_name in ("agentic-api", "agentic", "agentic-server"): + command_path = scripts_dir / command_name + ensure(command_path.is_file(), f"installed executable not found: {command_path}") + ensure(os.access(command_path, os.X_OK), f"installed executable is not executable: {command_path}") + + +def run_command(command: list[str], failure_prefix: str) -> str: + try: + completed = subprocess.run(command, check=True, capture_output=True, text=True) + except OSError as error: + fail(f"{failure_prefix}: unable to launch {' '.join(command)}: {error}") + except subprocess.CalledProcessError as error: + output = (error.stdout or error.stderr or "").strip() + if output: + fail(f"{failure_prefix}: {' '.join(command)} failed: {output}") + fail(f"{failure_prefix}: {' '.join(command)} exited with status {error.returncode}") + + output = (completed.stdout or completed.stderr).strip() + ensure(output, f"{failure_prefix}: {' '.join(command)} produced no output") + return output + + +agentic_version_output = run_command([str(scripts_dir / "agentic"), "--version"], "agentic version check") +ensure( + expected_version in agentic_version_output.splitlines()[0], + f"agentic version output missing {expected_version}: {agentic_version_output}", +) + +server_version_output = run_command([str(scripts_dir / "agentic-server"), "--version"], "agentic-server version check") +ensure( + f"agentic-server {expected_version}" in server_version_output.splitlines()[0], + f"agentic-server version output missing {expected_version}: {server_version_output}", +) + +launcher_version_output = run_command([str(scripts_dir / "agentic-api"), "version"], "agentic-api version check") +ensure( + f"agentic-api version: {expected_version}" in launcher_version_output, + f"agentic-api version output missing {expected_version}: {launcher_version_output}", +) +ensure( + f"Rust binary version: agentic-server {expected_version}" in launcher_version_output, + ( + "agentic-api version output missing packaged Rust binary version " + f"{expected_version}: {launcher_version_output}" + ), +) + +print(f"wheel validation passed: {wheel_path.name}") +PY diff --git a/scripts/validate-python-release-version.sh b/scripts/validate-python-release-version.sh new file mode 100755 index 00000000..98dc778d --- /dev/null +++ b/scripts/validate-python-release-version.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +requested_version="${AGENTIC_API_RELEASE_VERSION:-}" +if [[ "$requested_version" != "0.4.0" ]]; then + echo "release-python.yml is a 0.4.0 build-only workflow; other versions are rejected" >&2 + exit 1 +fi diff --git a/tests/python/test_binary.py b/tests/python/test_binary.py new file mode 100644 index 00000000..320d090d --- /dev/null +++ b/tests/python/test_binary.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from agentic_api.binary import ( + PackagedBinaryNotFoundError, + find_packaged_binary, + read_binary_version, +) + + +def test_find_packaged_binary_prefers_scripts_directory_over_global_binary( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + scripts_dir = tmp_path / "env" / "bin" + scripts_dir.mkdir(parents=True) + local_binary = scripts_dir / "agentic-server" + local_binary.write_text("#!/bin/sh\nexit 0\n") + local_binary.chmod(0o755) + + global_binary = tmp_path / "global" / "agentic-server" + global_binary.parent.mkdir(parents=True) + global_binary.write_text("#!/bin/sh\nexit 0\n") + global_binary.chmod(0o755) + + monkeypatch.setattr("agentic_api.binary.sysconfig.get_path", lambda name: str(scripts_dir)) + monkeypatch.setattr("agentic_api.binary.sys.executable", str(tmp_path / "env" / "bin" / "python")) + monkeypatch.setattr("agentic_api.binary.shutil.which", lambda name: str(global_binary)) + + assert find_packaged_binary("agentic-server") == local_binary + + +@pytest.mark.parametrize("path_exists", [False, True]) +def test_find_packaged_binary_reports_remediation_when_packaged_binary_is_missing_or_not_executable( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, path_exists: bool +) -> None: + scripts_dir = tmp_path / "env" / "bin" + scripts_dir.mkdir(parents=True) + local_binary = scripts_dir / "agentic-server" + if path_exists: + local_binary.write_text("#!/bin/sh\nexit 0\n") + local_binary.chmod(0o644) + + monkeypatch.setattr("agentic_api.binary.sysconfig.get_path", lambda name: str(scripts_dir)) + monkeypatch.setattr("agentic_api.binary.sys.executable", str(tmp_path / "env" / "bin" / "python")) + monkeypatch.setattr("agentic_api.binary.shutil.which", lambda name: None) + + with pytest.raises(PackagedBinaryNotFoundError, match="Reinstall agentic-api for this platform"): + find_packaged_binary("agentic-server") + + +def test_read_binary_version_returns_first_line_from_version_output(tmp_path: Path) -> None: + binary = tmp_path / "agentic-server" + binary.write_text("#!/bin/sh\nprintf 'agentic-server 0.4.0\\nextra detail\\n'\n") + binary.chmod(0o755) + + assert read_binary_version(binary) == "agentic-server 0.4.0" diff --git a/tests/python/test_cli.py b/tests/python/test_cli.py new file mode 100644 index 00000000..80bcbb60 --- /dev/null +++ b/tests/python/test_cli.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +from dataclasses import asdict + +import pytest + +from agentic_api.cli import ServeOptions, build_parser, main + + +def parse_serve_args(*args: str) -> ServeOptions: + parser = build_parser() + namespace = parser.parse_args(["serve", *args]) + assert namespace.command == "serve" + assert isinstance(namespace.options, ServeOptions) + return namespace.options + + +def test_serve_with_model_uses_local_mode_defaults() -> None: + options = parse_serve_args("--model", "Qwen/Qwen3-4B") + + assert asdict(options) == { + "mode": "local", + "model": "Qwen/Qwen3-4B", + "vllm_base_url": None, + "host": "0.0.0.0", + "port": 9000, + "startup_timeout_s": 600.0, + "shutdown_timeout_s": 10.0, + "vllm_port": 8000, + "gateway_api_key_env": "OPENAI_API_KEY", + "vllm_api_key_env": "AGENTIC_VLLM_API_KEY", + "vllm_args": [], + } + + +def test_serve_with_vllm_base_url_uses_remote_mode() -> None: + options = parse_serve_args("--vllm-base-url", "http://existing-vllm:8000") + + assert options.mode == "remote" + assert options.model is None + assert options.vllm_base_url == "http://existing-vllm:8000" + + +@pytest.mark.parametrize( + ("args", "message"), + [ + ([], "exactly one of --model or --vllm-base-url is required"), + ( + ["--model", "Qwen/Qwen3-4B", "--vllm-base-url", "http://existing-vllm:8000"], + "exactly one of --model or --vllm-base-url is required", + ), + ], +) +def test_serve_requires_exactly_one_source_option(args: list[str], message: str, capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as exc_info: + build_parser().parse_args(["serve", *args]) + + assert exc_info.value.code == 2 + assert message in capsys.readouterr().err + + +def test_serve_preserves_passthrough_after_double_dash() -> None: + options = parse_serve_args( + "--model", + "Qwen/Qwen3-4B", + "--", + "--dtype", + "bfloat16", + "--max-model-len=32768", + ) + + assert options.vllm_args == ["--dtype", "bfloat16", "--max-model-len=32768"] + + +@pytest.mark.parametrize( + "args", + [ + ["--model", "Qwen/Qwen3-4B", "--", "--host", "127.0.0.1"], + ["--model", "Qwen/Qwen3-4B", "--", "--host=127.0.0.1"], + ["--model", "Qwen/Qwen3-4B", "--", "--port", "9999"], + ["--model", "Qwen/Qwen3-4B", "--", "--port=9999"], + ["--model", "Qwen/Qwen3-4B", "--", "--api-key", "secret"], + ["--model", "Qwen/Qwen3-4B", "--", "--api-key=secret"], + ["--model", "Qwen/Qwen3-4B", "--", "--api_key", "secret"], + ["--model", "Qwen/Qwen3-4B", "--", "--api_key=secret"], + ["--model", "Qwen/Qwen3-4B", "--", "--uds", "/tmp/vllm.sock"], + ["--model", "Qwen/Qwen3-4B", "--", "--uds=/tmp/vllm.sock"], + ], +) +def test_serve_rejects_launcher_owned_vllm_passthrough_flags( + args: list[str], capsys: pytest.CaptureFixture[str] +) -> None: + with pytest.raises(SystemExit) as exc_info: + build_parser().parse_args(["serve", *args]) + + assert exc_info.value.code == 2 + assert "reserved for the launcher" in capsys.readouterr().err + + +@pytest.mark.parametrize( + ("flag", "value"), + [ + ("--port", "0"), + ("--port", "65536"), + ("--vllm-port", "-1"), + ("--vllm-port", "65536"), + ], +) +def test_serve_rejects_ports_outside_tcp_range( + flag: str, value: str, capsys: pytest.CaptureFixture[str] +) -> None: + with pytest.raises(SystemExit) as exc_info: + build_parser().parse_args(["serve", "--model", "Qwen/Qwen3-4B", f"{flag}={value}"]) + + assert exc_info.value.code == 2 + assert "must be between 1 and 65535" in capsys.readouterr().err + + +@pytest.mark.parametrize( + ("flag", "value"), + [ + ("--startup-timeout-s", "nan"), + ("--startup-timeout-s", "inf"), + ("--startup-timeout-s", "-inf"), + ("--startup-timeout-s", "0"), + ("--startup-timeout-s", "-1"), + ("--startup-timeout-s", "86400.1"), + ("--shutdown-timeout-s", "nan"), + ("--shutdown-timeout-s", "inf"), + ("--shutdown-timeout-s", "0"), + ("--shutdown-timeout-s", "-1"), + ("--shutdown-timeout-s", "86400.1"), + ], +) +def test_serve_rejects_non_finite_non_positive_or_excessive_timeouts( + flag: str, value: str, capsys: pytest.CaptureFixture[str] +) -> None: + with pytest.raises(SystemExit) as exc_info: + build_parser().parse_args(["serve", "--model", "Qwen/Qwen3-4B", f"{flag}={value}"]) + + assert exc_info.value.code == 2 + assert "must be finite and greater than 0" in capsys.readouterr().err + + +def test_serve_accepts_port_and_timeout_boundaries() -> None: + options = parse_serve_args( + "--model", + "Qwen/Qwen3-4B", + "--port", + "1", + "--vllm-port", + "65535", + "--startup-timeout-s", + "0.001", + "--shutdown-timeout-s", + "86400", + ) + + assert options.port == 1 + assert options.vllm_port == 65535 + assert options.startup_timeout_s == 0.001 + assert options.shutdown_timeout_s == 86400.0 + + +def test_doctor_subcommand_accepts_optional_mode() -> None: + parser = build_parser() + + namespace = parser.parse_args(["doctor", "--mode", "local"]) + assert namespace.command == "doctor" + assert namespace.mode == "local" + + namespace = parser.parse_args(["doctor"]) + assert namespace.command == "doctor" + assert namespace.mode is None + + +def test_version_subcommand_exits_successfully( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr("agentic_api.cli.version_report", lambda: "version report") + + exit_code = main(["version"]) + + assert exit_code == 0 + assert capsys.readouterr().out == "version report\n" diff --git a/tests/python/test_diagnostics.py b/tests/python/test_diagnostics.py new file mode 100644 index 00000000..860b609b --- /dev/null +++ b/tests/python/test_diagnostics.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import importlib +from pathlib import Path + +import pytest + +from agentic_api import diagnostics +from agentic_api.compatibility import SUPPORTED_VLLM_VERSION + + +def test_remote_doctor_is_healthy_without_vllm( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path +) -> None: + rust_binary = tmp_path / "agentic-server" + rust_binary.write_text("") + rust_binary.chmod(0o755) + + monkeypatch.setattr("agentic_api.diagnostics.find_packaged_binary", lambda name: rust_binary) + monkeypatch.setattr("agentic_api.diagnostics.read_binary_version", lambda path: "agentic-server 0.4.0") + monkeypatch.setattr("agentic_api.diagnostics.metadata_version", _metadata_version_without_vllm) + monkeypatch.setattr( + "agentic_api.diagnostics.find_active_environment_executable", + lambda name: (_ for _ in ()).throw(FileNotFoundError(name)), + ) + + exit_code = diagnostics.doctor("remote") + output = capsys.readouterr().out + + assert exit_code == 0 + assert "Selected mode: remote" in output + assert "Installed vLLM version: not installed" in output + assert "Local mode health: unavailable" in output + assert "Remote mode health: ok" in output + + +def test_local_doctor_reports_missing_vllm_installation( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path +) -> None: + rust_binary = tmp_path / "agentic-server" + rust_binary.write_text("") + rust_binary.chmod(0o755) + + monkeypatch.setattr("agentic_api.diagnostics.find_packaged_binary", lambda name: rust_binary) + monkeypatch.setattr("agentic_api.diagnostics.read_binary_version", lambda path: "agentic-server 0.4.0") + monkeypatch.setattr("agentic_api.diagnostics.metadata_version", _metadata_version_without_vllm) + monkeypatch.setattr( + "agentic_api.diagnostics.find_active_environment_executable", + lambda name: (_ for _ in ()).throw(FileNotFoundError(name)), + ) + + exit_code = diagnostics.doctor("local") + output = capsys.readouterr().out + + assert exit_code == 1 + assert "Selected mode: local" in output + assert "Local mode health: unavailable" in output + assert "Install the 0.4.0 wheel artifact with its local extra" in output + assert "file:///path/to/agentic_api-0.4.0-PLATFORM.whl" in output + + +def test_local_doctor_reports_incompatible_vllm_version( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path +) -> None: + rust_binary = tmp_path / "agentic-server" + rust_binary.write_text("") + rust_binary.chmod(0o755) + vllm_binary = tmp_path / "vllm" + vllm_binary.write_text("") + vllm_binary.chmod(0o755) + + monkeypatch.setattr("agentic_api.diagnostics.find_packaged_binary", lambda name: rust_binary) + monkeypatch.setattr("agentic_api.diagnostics.read_binary_version", lambda path: "agentic-server 0.4.0") + monkeypatch.setattr("agentic_api.diagnostics.metadata_version", _metadata_version_with_incompatible_vllm) + monkeypatch.setattr("agentic_api.diagnostics.find_active_environment_executable", lambda name: vllm_binary) + + exit_code = diagnostics.doctor("local") + output = capsys.readouterr().out + + assert exit_code == 1 + assert f"Supported vLLM version: {SUPPORTED_VLLM_VERSION}" in output + assert "Installed vLLM version: 0.12.0" in output + assert "Local mode health: unavailable" in output + assert "Installed vLLM does not match the tested version" in output + + +def test_local_doctor_finds_vllm_in_active_environment_when_it_is_not_on_path( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + rust_binary = tmp_path / "agentic-server" + rust_binary.write_text("") + rust_binary.chmod(0o755) + scripts_dir = tmp_path / "environment" / "bin" + scripts_dir.mkdir(parents=True) + vllm_binary = scripts_dir / "vllm" + vllm_binary.write_text("") + vllm_binary.chmod(0o755) + + monkeypatch.setattr("agentic_api.diagnostics.find_packaged_binary", lambda name: rust_binary) + monkeypatch.setattr("agentic_api.diagnostics.read_binary_version", lambda path: "agentic-server 0.4.0") + monkeypatch.setattr("agentic_api.diagnostics.metadata_version", _metadata_version_with_supported_vllm) + monkeypatch.setattr("agentic_api.binary.sysconfig.get_path", lambda name: str(scripts_dir)) + monkeypatch.setattr("agentic_api.binary.sys.executable", str(scripts_dir / "python")) + monkeypatch.setenv("PATH", "") + + report = diagnostics.collect_doctor_report() + + assert report.local_ok is True + assert report.vllm_executable_path == str(vllm_binary) + + +def test_doctor_report_includes_platform_package_and_binary_details( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path +) -> None: + rust_binary = tmp_path / "agentic-server" + rust_binary.write_text("") + rust_binary.chmod(0o755) + vllm_binary = tmp_path / "vllm" + vllm_binary.write_text("") + vllm_binary.chmod(0o755) + + monkeypatch.setattr("agentic_api.diagnostics.find_packaged_binary", lambda name: rust_binary) + monkeypatch.setattr("agentic_api.diagnostics.read_binary_version", lambda path: "agentic-server 0.4.0") + monkeypatch.setattr("agentic_api.diagnostics.metadata_version", _metadata_version_with_supported_vllm) + monkeypatch.setattr("agentic_api.diagnostics.find_active_environment_executable", lambda name: vllm_binary) + monkeypatch.setattr("agentic_api.diagnostics.platform.system", lambda: "Linux") + monkeypatch.setattr("agentic_api.diagnostics.platform.machine", lambda: "x86_64") + monkeypatch.setattr("agentic_api.diagnostics.platform.python_version", lambda: "3.12.4") + + exit_code = diagnostics.doctor(None) + output = capsys.readouterr().out + + assert exit_code == 0 + assert "Python version: 3.12.4" in output + assert "Platform: Linux x86_64" in output + assert "agentic-api version: 0.4.0" in output + assert f"Supported vLLM version: {SUPPORTED_VLLM_VERSION}" in output + assert f"Rust binary path: {rust_binary}" in output + assert "Rust binary executable: yes" in output + assert "Rust binary version: agentic-server 0.4.0" in output + assert f"vLLM executable path: {vllm_binary}" in output + + +def test_python_module_entrypoint_delegates_to_cli_main(monkeypatch: pytest.MonkeyPatch) -> None: + module = importlib.import_module("agentic_api.__main__") + called: list[object] = [] + + monkeypatch.setattr("agentic_api.cli.main", lambda argv=None: called.append(argv) or 7) + + assert module.main() == 7 + assert called == [None] + + +def _metadata_version_without_vllm(name: str) -> str: + if name == "agentic-api": + return "0.4.0" + raise diagnostics.PackageNotFoundError(name) + + +def _metadata_version_with_incompatible_vllm(name: str) -> str: + if name == "agentic-api": + return "0.4.0" + if name == "vllm": + return "0.12.0" + raise diagnostics.PackageNotFoundError(name) + + +def _metadata_version_with_supported_vllm(name: str) -> str: + if name == "agentic-api": + return "0.4.0" + if name == "vllm": + return SUPPORTED_VLLM_VERSION + raise diagnostics.PackageNotFoundError(name) diff --git a/tests/python/test_docs_examples.py b/tests/python/test_docs_examples.py new file mode 100644 index 00000000..1f0455c8 --- /dev/null +++ b/tests/python/test_docs_examples.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +README = REPO_ROOT / "README.md" +DOCS_INDEX = REPO_ROOT / "docs" / "index.md" +INSTALL_GUIDE = REPO_ROOT / "docs" / "guides" / "python-installation.md" + + +def test_documented_python_install_commands_respect_release_publication_gate() -> None: + guide = INSTALL_GUIDE.read_text(encoding="utf-8") + readme = README.read_text(encoding="utf-8") + index = DOCS_INDEX.read_text(encoding="utf-8") + + combined = "\n".join((readme, index, guide)) + + assert 'uv pip install "$WHEEL_PATH"' in combined + assert 'agentic-api[local] @ file://$WHEEL_PATH' in combined + assert "0.4.0 is a build-only release" in combined + assert "Planned for 0.5.0" in combined + assert "after the PyPI publication gate" in combined + assert "uv pip install agentic-api" in combined + assert 'uv pip install "agentic-api[local]"' in combined + assert "uvx --from agentic-api agentic-api doctor" in combined + assert "uvx --from agentic-api agentic-api serve --vllm-base-url http://existing-vllm:8000" in combined + assert "uvx pip install" not in combined + + +def test_python_install_guide_covers_workflows_and_backend_language() -> None: + guide = INSTALL_GUIDE.read_text(encoding="utf-8") + readme = README.read_text(encoding="utf-8") + + assert "agentic-api serve --vllm-base-url http://existing-vllm:8000" in guide + assert "agentic-api serve --model Qwen/Qwen3-30B-A3B-FP8" in guide + assert "agentic-api doctor --mode remote" in guide + assert "agentic run codex --model MODEL_ID" in guide + assert "agentic run claude --model SERVED_MODEL_ALIAS" in guide + assert "vLLM is one supported backend, not part of the Agentic API product name" in readme + assert "The Rust-native `agentic` CLI remains supported" in guide + + +def test_python_install_guide_documents_known_good_model_profiles() -> None: + guide = INSTALL_GUIDE.read_text(encoding="utf-8") + + assert "documentation data, not an allowlist" in guide + assert "Qwen/Qwen3-30B-A3B-FP8" in guide + assert "qwen3-30b-a3b-fp8" in guide + assert "vllm serve Qwen/Qwen3-30B-A3B-FP8 --reasoning-parser deepseek_r1 --port 5050" in guide + assert "vllm serve Qwen/Qwen3-30B-A3B-FP8 --tool-call-parser hermes --enable-auto-tool-choice --port 5050" in guide + assert "Qwen/Qwen3.5-35B-A3B-FP8" in guide + assert "Qwen/Qwen3.8-27B-FP8" in guide diff --git a/tests/python/test_installed_cli.py b/tests/python/test_installed_cli.py new file mode 100644 index 00000000..99775cf8 --- /dev/null +++ b/tests/python/test_installed_cli.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import sysconfig +from pathlib import Path + +import pytest + + +COMMAND_TIMEOUT_S = 20 + + +def scripts_dir() -> Path: + path = sysconfig.get_path("scripts") + assert path is not None + return Path(path).resolve() + + +def test_installed_wheel_exposes_expected_commands() -> None: + directory = scripts_dir() + + for name in ("agentic-api", "agentic", "agentic-server"): + path = directory / name + assert path.is_file(), f"expected installed executable at {path}" + assert os.access(path, os.X_OK), f"expected executable permissions on {path}" + + +def test_agentic_api_serve_uses_discovered_packaged_server_without_importing_vllm(tmp_path: Path) -> None: + directory = scripts_dir() + server_path = directory / "agentic-server" + backup_path = tmp_path / "agentic-server.real" + record_path = tmp_path / "agentic-server-record.json" + guard_path = tmp_path / "sitecustomize.py" + + shutil.move(server_path, backup_path) + try: + server_path.write_text( + "\n".join( + [ + "#!/usr/bin/env python3", + "from __future__ import annotations", + "import json", + "import os", + "import sys", + "", + "record_path = os.environ['AGENTIC_TEST_RECORD_PATH']", + "with open(record_path, 'w', encoding='utf-8') as handle:", + " json.dump(", + " {", + " 'argv': sys.argv[1:],", + " 'openai_api_key': os.environ.get('OPENAI_API_KEY'),", + " },", + " handle,", + " )", + "raise SystemExit(0)", + ] + ) + + "\n", + encoding="utf-8", + ) + server_path.chmod(0o755) + + guard_path.write_text( + "\n".join( + [ + "from __future__ import annotations", + "import builtins", + "", + "_real_import = builtins.__import__", + "", + "def guarded_import(name, globals=None, locals=None, fromlist=(), level=0):", + " if name == 'vllm' or name.startswith('vllm.'):", + " raise RuntimeError(f'unexpected vllm import: {name}')", + " return _real_import(name, globals, locals, fromlist, level)", + "", + "builtins.__import__ = guarded_import", + ] + ) + + "\n", + encoding="utf-8", + ) + + env = os.environ.copy() + env["AGENTIC_TEST_RECORD_PATH"] = str(record_path) + env["CUSTOM_GATEWAY_KEY"] = "wheel-remote-key" + env["PYTHONPATH"] = str(tmp_path) + + result = subprocess.run( + [ + str(directory / "agentic-api"), + "serve", + "--vllm-base-url", + "https://upstream.example.test/base", + "--host", + "127.0.0.1", + "--port", + "7777", + "--gateway-api-key-env", + "CUSTOM_GATEWAY_KEY", + ], + capture_output=True, + text=True, + timeout=COMMAND_TIMEOUT_S, + env=env, + ) + + assert result.returncode == 0, result.stderr + record = json.loads(record_path.read_text(encoding="utf-8")) + assert record == { + "argv": [ + "--llm-api-base", + "https://upstream.example.test/base", + "--gateway-host", + "127.0.0.1", + "--gateway-port", + "7777", + ], + "openai_api_key": "wheel-remote-key", + } + finally: + if backup_path.exists(): + if server_path.exists(): + server_path.unlink() + shutil.move(backup_path, server_path) + + +@pytest.mark.skipif(os.name != "posix", reason="prefix script layout is POSIX-specific") +def test_agentic_api_entry_point_works_in_prefix_without_sibling_python(tmp_path: Path) -> None: + wheel_value = os.environ.get("AGENTIC_API_TEST_WHEEL") + if wheel_value is None: + pytest.skip("set AGENTIC_API_TEST_WHEEL to exercise the built wheel") + + wheel_path = Path(wheel_value).resolve() + assert wheel_path.is_file(), f"wheel does not exist: {wheel_path}" + uv = shutil.which("uv") + assert uv is not None, "uv is required for the prefix-install regression" + + prefix = tmp_path / "prefix" + install = subprocess.run( + [uv, "pip", "install", "--python", sys.executable, "--prefix", str(prefix), str(wheel_path)], + capture_output=True, + text=True, + timeout=COMMAND_TIMEOUT_S, + ) + assert install.returncode == 0, install.stderr + + prefix_bin = prefix / "bin" + command = prefix_bin / "agentic-api" + assert command.is_file() + assert not (prefix_bin / "python").exists() + + site_packages = list((prefix / "lib").glob("python*/site-packages")) + assert len(site_packages) == 1 + env = os.environ.copy() + env["PATH"] = os.pathsep.join((str(prefix_bin), env.get("PATH", ""))) + env["PYTHONPATH"] = str(site_packages[0]) + + result = subprocess.run( + [str(command), "version"], + capture_output=True, + text=True, + timeout=COMMAND_TIMEOUT_S, + env=env, + ) + + assert result.returncode == 0, result.stderr + assert "agentic-api version: 0.4.0" in result.stdout diff --git a/tests/python/test_launcher.py b/tests/python/test_launcher.py new file mode 100644 index 00000000..973d8a1e --- /dev/null +++ b/tests/python/test_launcher.py @@ -0,0 +1,289 @@ +from __future__ import annotations + +import os +import signal +from pathlib import Path +from typing import Any, Callable + +import pytest + +from agentic_api.cli import ServeOptions +from agentic_api.process import ChildResult, ShutdownRequested + + +class FakeChild: + def __init__(self, pid: int) -> None: + self.pid = pid + + def poll(self) -> int | None: + return None + + +class FakeSupervisor: + instances: list["FakeSupervisor"] = [] + + def __init__(self) -> None: + self.starts: list[tuple[list[str], dict[str, str]]] = [] + self.terminate_timeout: float | None = None + self.children = [FakeChild(pid=101), FakeChild(pid=202)] + self.wait_result = ChildResult(name="agentic-server", command=("agentic-server",), returncode=0) + self.wait_callback: Callable[[], None] | None = None + self.shutdown_requests: list[int | None] = [] + type(self).instances.append(self) + + def start(self, command: list[str], env: dict[str, str]) -> FakeChild: + self.starts.append((command, env)) + return self.children[len(self.starts) - 1] + + def terminate_all(self, timeout: float) -> None: + self.terminate_timeout = timeout + + def request_shutdown(self, signal_number: int | None = None) -> None: + self.shutdown_requests.append(signal_number) + + def shutdown_requested(self) -> bool: + return bool(self.shutdown_requests) + + def wait_for_failure(self) -> ChildResult: + if self.wait_callback is not None: + self.wait_callback() + return self.wait_result + + +def make_options(**overrides: Any) -> ServeOptions: + values: dict[str, Any] = { + "mode": "local", + "model": "Qwen/Qwen3-4B", + "vllm_base_url": None, + "host": "0.0.0.0", + "port": 9000, + "startup_timeout_s": 600.0, + "shutdown_timeout_s": 10.0, + "vllm_port": 8000, + "gateway_api_key_env": "OPENAI_API_KEY", + "vllm_api_key_env": "AGENTIC_VLLM_API_KEY", + "vllm_args": [], + } + values.update(overrides) + return ServeOptions(**values) + + +@pytest.fixture(autouse=True) +def clear_fake_supervisors() -> None: + FakeSupervisor.instances.clear() + + +def test_run_serve_local_mode_starts_vllm_then_rust(monkeypatch: pytest.MonkeyPatch) -> None: + import agentic_api.launcher as launcher + + wait_calls: list[tuple[str, str | None, float, float]] = [] + signal_handlers: dict[int, Any] = {} + + monkeypatch.setattr(launcher, "ProcessSupervisor", FakeSupervisor) + monkeypatch.setattr(launcher, "find_packaged_binary", lambda name: Path("/pkg/bin/agentic-server")) + monkeypatch.setattr(launcher, "find_active_environment_executable", lambda name: Path("/venv/bin/vllm")) + monkeypatch.setattr(launcher, "_installed_vllm_version", lambda: "0.11.0") + monkeypatch.setattr(launcher.secrets, "token_urlsafe", lambda _: "generated-token") + monkeypatch.setattr( + launcher, + "wait_for_vllm_ready", + lambda base_url, api_key, process, timeout, interval, shutdown_requested=None: wait_calls.append( + (base_url, api_key, timeout, interval) + ), + ) + monkeypatch.setattr(launcher.signal, "signal", lambda sig, handler: signal_handlers.setdefault(sig, handler)) + + exit_code = launcher.run_serve( + make_options(vllm_args=["--dtype", "bfloat16", "--max-model-len=32768"]), + ) + + supervisor = FakeSupervisor.instances[-1] + + assert exit_code == 0 + assert supervisor.starts[0][0] == [ + "/venv/bin/vllm", + "serve", + "Qwen/Qwen3-4B", + "--dtype", + "bfloat16", + "--max-model-len=32768", + "--host", + "127.0.0.1", + "--port", + "8000", + "--api-key", + "generated-token", + ] + assert supervisor.starts[1][0] == [ + "/pkg/bin/agentic-server", + "--llm-api-base", + "http://127.0.0.1:8000", + "--gateway-host", + "0.0.0.0", + "--gateway-port", + "9000", + ] + assert supervisor.starts[1][1]["OPENAI_API_KEY"] == "generated-token" + assert wait_calls == [("http://127.0.0.1:8000", "generated-token", 600.0, 2.0)] + assert signal.SIGINT in signal_handlers + assert signal.SIGTERM in signal_handlers + assert supervisor.terminate_timeout == 10.0 + + +def test_run_serve_local_mode_returns_sigint_during_readiness_without_startup_error( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + import agentic_api.launcher as launcher + + signal_handlers: dict[int, Any] = {} + supervisor = FakeSupervisor() + + monkeypatch.setattr(launcher, "ProcessSupervisor", lambda: supervisor) + monkeypatch.setattr(launcher, "find_packaged_binary", lambda name: Path("/pkg/bin/agentic-server")) + monkeypatch.setattr(launcher, "find_active_environment_executable", lambda name: Path("/venv/bin/vllm")) + monkeypatch.setattr(launcher, "_installed_vllm_version", lambda: "0.11.0") + monkeypatch.setattr(launcher.secrets, "token_urlsafe", lambda _: "generated-token") + + def fake_wait_for_vllm_ready( + base_url: str, + api_key: str | None, + process: FakeChild, + timeout: float, + interval: float, + shutdown_requested: Callable[[], bool] | None = None, + ) -> None: + del base_url, api_key, process, timeout, interval + signal_handlers[signal.SIGINT](signal.SIGINT, None) + assert shutdown_requested is not None + assert shutdown_requested() is True + raise ShutdownRequested() + + monkeypatch.setattr(launcher, "wait_for_vllm_ready", fake_wait_for_vllm_ready) + monkeypatch.setattr(launcher.signal, "signal", lambda sig, handler: signal_handlers.setdefault(sig, handler)) + + exit_code = launcher.run_serve(make_options()) + + assert exit_code == 130 + assert capsys.readouterr().err == "" + assert len(supervisor.starts) == 1 + assert supervisor.shutdown_requests == [signal.SIGINT] + assert supervisor.terminate_timeout == 10.0 + + +def test_run_serve_remote_mode_starts_only_rust_and_uses_selected_gateway_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import agentic_api.launcher as launcher + + monkeypatch.setattr(launcher, "ProcessSupervisor", FakeSupervisor) + monkeypatch.setattr(launcher, "find_packaged_binary", lambda name: Path("/pkg/bin/agentic-server")) + monkeypatch.setattr(launcher.signal, "signal", lambda sig, handler: handler) + monkeypatch.setenv("CUSTOM_GATEWAY_KEY", "remote-secret") + + supervisor = FakeSupervisor() + supervisor.wait_result = ChildResult(name="agentic-server", command=("agentic-server",), returncode=17) + monkeypatch.setattr(launcher, "ProcessSupervisor", lambda: supervisor) + + exit_code = launcher.run_serve( + make_options( + mode="remote", + model=None, + vllm_base_url="https://upstream.example.com/base", + gateway_api_key_env="CUSTOM_GATEWAY_KEY", + ) + ) + + assert exit_code == 17 + assert supervisor.starts[0][0] == [ + "/pkg/bin/agentic-server", + "--llm-api-base", + "https://upstream.example.com/base", + "--gateway-host", + "0.0.0.0", + "--gateway-port", + "9000", + ] + assert supervisor.starts[0][1]["OPENAI_API_KEY"] == "remote-secret" + assert supervisor.terminate_timeout == 10.0 + + +def test_run_serve_reports_startup_failure_and_cleans_up_started_children( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + import agentic_api.launcher as launcher + + supervisor = FakeSupervisor() + + monkeypatch.setattr(launcher, "ProcessSupervisor", lambda: supervisor) + monkeypatch.setattr(launcher, "find_packaged_binary", lambda name: Path("/pkg/bin/agentic-server")) + monkeypatch.setattr(launcher, "find_active_environment_executable", lambda name: Path("/venv/bin/vllm")) + monkeypatch.setattr(launcher, "_installed_vllm_version", lambda: "0.11.0") + monkeypatch.setattr(launcher.secrets, "token_urlsafe", lambda _: "generated-token") + monkeypatch.setattr( + launcher, + "wait_for_vllm_ready", + lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("timed out waiting for vLLM readiness")), + ) + monkeypatch.setattr(launcher.signal, "signal", lambda sig, handler: handler) + + exit_code = launcher.run_serve(make_options()) + + assert exit_code == 1 + assert len(supervisor.starts) == 1 + assert "timed out waiting for vLLM readiness" in capsys.readouterr().err + assert supervisor.terminate_timeout == 10.0 + + +def test_run_serve_restores_prior_signal_handlers(monkeypatch: pytest.MonkeyPatch) -> None: + import agentic_api.launcher as launcher + + previous_sigint = object() + previous_sigterm = object() + registered_handlers = { + signal.SIGINT: previous_sigint, + signal.SIGTERM: previous_sigterm, + } + + def fake_signal(sig: int, handler: Any) -> Any: + previous = registered_handlers[sig] + registered_handlers[sig] = handler + return previous + + supervisor = FakeSupervisor() + monkeypatch.setattr(launcher, "ProcessSupervisor", lambda: supervisor) + monkeypatch.setattr(launcher, "find_packaged_binary", lambda name: Path("/pkg/bin/agentic-server")) + monkeypatch.setattr(launcher.signal, "signal", fake_signal) + + exit_code = launcher.run_serve( + make_options(mode="remote", model=None, vllm_base_url="https://upstream.example.com/base") + ) + + assert exit_code == 0 + assert registered_handlers[signal.SIGINT] is previous_sigint + assert registered_handlers[signal.SIGTERM] is previous_sigterm + + +def test_run_serve_uses_a_single_shutdown_path_for_repeated_signals(monkeypatch: pytest.MonkeyPatch) -> None: + import agentic_api.launcher as launcher + + supervisor = FakeSupervisor() + signal_handlers: dict[int, Any] = {} + + monkeypatch.setattr(launcher, "ProcessSupervisor", lambda: supervisor) + monkeypatch.setattr(launcher, "find_packaged_binary", lambda name: Path("/pkg/bin/agentic-server")) + monkeypatch.setattr(launcher.signal, "signal", lambda sig, handler: signal_handlers.setdefault(sig, handler)) + + def trigger_signal() -> None: + signal_handlers[signal.SIGTERM](signal.SIGTERM, None) + signal_handlers[signal.SIGTERM](signal.SIGTERM, None) + + supervisor.wait_callback = trigger_signal + supervisor.wait_result = ChildResult(name="agentic-server", command=("agentic-server",), returncode=-15) + + exit_code = launcher.run_serve( + make_options(mode="remote", model=None, vllm_base_url="https://upstream.example.com/base") + ) + + assert exit_code == 143 + assert supervisor.shutdown_requests == [signal.SIGTERM, signal.SIGTERM] + assert supervisor.terminate_timeout == 10.0 diff --git a/tests/python/test_metadata.py b/tests/python/test_metadata.py new file mode 100644 index 00000000..03afc725 --- /dev/null +++ b/tests/python/test_metadata.py @@ -0,0 +1,19 @@ +from importlib.metadata import distribution + +from agentic_api import __version__ +from agentic_api.compatibility import SUPPORTED_VLLM_VERSION + + +def test_installed_package_version_is_0_4_0() -> None: + installed_distribution = distribution("agentic-api") + assert installed_distribution.version == "0.4.0" + assert installed_distribution.metadata["Version"] == "0.4.0" + assert __version__ == installed_distribution.version + + +def test_supported_vllm_is_exactly_declared_in_linux_local_extra() -> None: + metadata = distribution("agentic-api").metadata + assert ( + f"vllm=={SUPPORTED_VLLM_VERSION} ; platform_system == 'Linux' and extra == 'local'" + in metadata.get_all("Requires-Dist") + ) diff --git a/tests/python/test_process.py b/tests/python/test_process.py new file mode 100644 index 00000000..f84bdac7 --- /dev/null +++ b/tests/python/test_process.py @@ -0,0 +1,466 @@ +from __future__ import annotations + +import os +import signal +import socket +import sys +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from subprocess import TimeoutExpired +from typing import Any + +import pytest + +from agentic_api.process import ChildResult, ProcessSupervisor, ShutdownRequested, wait_for_vllm_ready + + +class FakePopen: + def __init__(self, pid: int, poll_values: list[int | None] | None = None) -> None: + self.pid = pid + self._poll_values = list(poll_values or [None]) + self.returncode: int | None = None + self.terminate_calls = 0 + self.kill_calls = 0 + self.wait_calls = 0 + self.wait_timeout_values: list[float | None] = [] + self.wait_should_timeout = False + + def poll(self) -> int | None: + if self.returncode is not None: + return self.returncode + if len(self._poll_values) > 1: + return self._poll_values.pop(0) + return self._poll_values[0] + + def wait(self, timeout: float | None = None) -> int: + self.wait_calls += 1 + self.wait_timeout_values.append(timeout) + if self.returncode is not None: + return self.returncode + if self.wait_should_timeout: + self.wait_should_timeout = False + raise TimeoutExpired(cmd=["fake"], timeout=timeout) + polled = self.poll() + if polled is None: + self.returncode = 0 + else: + self.returncode = polled + return self.returncode + + def terminate(self) -> None: + self.terminate_calls += 1 + + def kill(self) -> None: + self.kill_calls += 1 + self.returncode = -9 + + +class DummyProcess: + def __init__(self, poll_values: list[int | None]) -> None: + self._poll_values = list(poll_values) + self.returncode: int | None = None + + def poll(self) -> int | None: + if self.returncode is not None: + return self.returncode + if len(self._poll_values) > 1: + result = self._poll_values.pop(0) + else: + result = self._poll_values[0] + if result is not None: + self.returncode = result + return result + + +class ModelsHandler(BaseHTTPRequestHandler): + requests_seen = 0 + auth_headers: list[str | None] = [] + response_statuses: list[int] = [200] + response_delay_s = 0.0 + + def do_GET(self) -> None: # noqa: N802 + type(self).requests_seen += 1 + type(self).auth_headers.append(self.headers.get("Authorization")) + if self.path != "/v1/models": + self.send_response(404) + self.end_headers() + return + + if type(self).response_delay_s: + time.sleep(type(self).response_delay_s) + + status = type(self).response_statuses[min(type(self).requests_seen - 1, len(type(self).response_statuses) - 1)] + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"data":[{"id":"model-a"}]}') + + def log_message(self, format: str, *args: Any) -> None: # noqa: A003 + del format, args + + +@pytest.fixture(autouse=True) +def reset_models_handler() -> None: + ModelsHandler.requests_seen = 0 + ModelsHandler.auth_headers = [] + ModelsHandler.response_statuses = [200] + ModelsHandler.response_delay_s = 0.0 + + +@pytest.fixture +def models_server() -> tuple[ThreadingHTTPServer, str]: + server = ThreadingHTTPServer(("127.0.0.1", 0), ModelsHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + host, port = server.server_address + try: + yield server, f"http://{host}:{port}" + finally: + server.shutdown() + thread.join(timeout=1) + server.server_close() + + +def reserve_tcp_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def test_start_uses_argument_list_and_inherits_stdio(monkeypatch: pytest.MonkeyPatch) -> None: + popen_calls: list[tuple[list[str], dict[str, Any]]] = [] + fake_process = FakePopen(pid=1234) + + def fake_popen(command: list[str], **kwargs: Any) -> FakePopen: + popen_calls.append((command, kwargs)) + return fake_process + + monkeypatch.setattr("agentic_api.process.subprocess.Popen", fake_popen) + + supervisor = ProcessSupervisor() + process = supervisor.start(["vllm", "serve", "Qwen/Qwen3-4B"], {"KEY": "value"}) + + assert process is fake_process + assert popen_calls == [ + ( + ["vllm", "serve", "Qwen/Qwen3-4B"], + { + "env": {"KEY": "value"}, + "shell": False, + "start_new_session": True, + }, + ) + ] + + +def test_terminate_all_sends_posix_group_signals_then_force_kills(monkeypatch: pytest.MonkeyPatch) -> None: + fake_process = FakePopen(pid=4321) + fake_process.wait_should_timeout = True + received_signals: list[tuple[int, int]] = [] + + def fake_killpg(pid: int, sig: int) -> None: + if sig == 0: + if fake_process.returncode is not None: + raise ProcessLookupError + return + received_signals.append((pid, sig)) + if sig == 9: + fake_process.returncode = -9 + + monkeypatch.setattr("agentic_api.process.subprocess.Popen", lambda command, **kwargs: fake_process) + monkeypatch.setattr("agentic_api.process.os.killpg", fake_killpg) + + supervisor = ProcessSupervisor() + supervisor.start(["agentic-server"], {}) + supervisor.terminate_all(timeout=0.01) + + assert received_signals == [(4321, 15), (4321, 9)] + assert fake_process.returncode == -9 + + +def test_terminate_all_reaps_exited_group_leader_without_waiting_for_deadline( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_process = FakePopen(pid=5432) + leader_exited = False + original_poll = fake_process.poll + + def fake_poll() -> int | None: + if leader_exited: + fake_process.returncode = -signal.SIGTERM + return original_poll() + + def fake_killpg(pid: int, sig: int) -> None: + nonlocal leader_exited + assert pid == fake_process.pid + if sig == signal.SIGTERM: + leader_exited = True + elif sig == 0 and fake_process.returncode is not None: + raise ProcessLookupError + + fake_process.poll = fake_poll # type: ignore[method-assign] + monkeypatch.setattr("agentic_api.process.subprocess.Popen", lambda command, **kwargs: fake_process) + monkeypatch.setattr("agentic_api.process.os.killpg", fake_killpg) + monkeypatch.setattr( + "agentic_api.process.time.sleep", + lambda _: pytest.fail("an exited, reaped process group should not wait for the shutdown deadline"), + ) + + supervisor = ProcessSupervisor() + supervisor.start(["agentic-server"], {}) + supervisor.terminate_all(timeout=10.0) + + assert fake_process.returncode == -signal.SIGTERM + + +def test_terminate_all_uses_direct_child_signals_off_posix(monkeypatch: pytest.MonkeyPatch) -> None: + fake_process = FakePopen(pid=99) + fake_process.wait_should_timeout = True + + monkeypatch.setattr("agentic_api.process.subprocess.Popen", lambda command, **kwargs: fake_process) + monkeypatch.setattr("agentic_api.process.os.name", "nt") + + supervisor = ProcessSupervisor() + supervisor.start(["agentic-server"], {}) + supervisor.terminate_all(timeout=0.01) + + assert fake_process.terminate_calls == 1 + assert fake_process.kill_calls == 1 + + +def test_wait_for_failure_returns_exited_child_status(monkeypatch: pytest.MonkeyPatch) -> None: + first = FakePopen(pid=1, poll_values=[None, None, None]) + second = FakePopen(pid=2, poll_values=[None, 7]) + + monkeypatch.setattr( + "agentic_api.process.subprocess.Popen", + lambda command, **kwargs: first if command[0] == "vllm" else second, + ) + monkeypatch.setattr("agentic_api.process.time.sleep", lambda _: None) + + supervisor = ProcessSupervisor() + supervisor.start(["vllm", "serve"], {}) + supervisor.start(["agentic-server"], {}) + + assert supervisor.wait_for_failure() == ChildResult( + name="agentic-server", + command=("agentic-server",), + returncode=7, + ) + + +def test_wait_for_failure_raises_shutdown_requested(monkeypatch: pytest.MonkeyPatch) -> None: + fake_process = FakePopen(pid=77, poll_values=[None, None, None]) + + monkeypatch.setattr("agentic_api.process.subprocess.Popen", lambda command, **kwargs: fake_process) + supervisor = ProcessSupervisor() + supervisor.start(["agentic-server"], {}) + supervisor.request_shutdown(signal.SIGTERM) + + with pytest.raises(ShutdownRequested): + supervisor.wait_for_failure() + + +def test_start_terminates_new_child_immediately_after_shutdown_request(monkeypatch: pytest.MonkeyPatch) -> None: + first = FakePopen(pid=100) + second = FakePopen(pid=200) + popen_results = iter([first, second]) + received_signals: list[tuple[int, int]] = [] + + def fake_killpg(pid: int, sig: int) -> None: + if sig == 0: + process = first if pid == 100 else second + if process.returncode is not None: + raise ProcessLookupError + return + received_signals.append((pid, sig)) + if sig == signal.SIGTERM: + if pid == 100: + first.returncode = -15 + if pid == 200: + second.returncode = -15 + + monkeypatch.setattr("agentic_api.process.subprocess.Popen", lambda command, **kwargs: next(popen_results)) + monkeypatch.setattr("agentic_api.process.os.killpg", fake_killpg) + + supervisor = ProcessSupervisor() + supervisor.start(["vllm"], {}) + supervisor.request_shutdown(signal.SIGTERM) + supervisor.terminate_all(timeout=0.01) + supervisor.start(["agentic-server"], {}) + + assert received_signals == [(100, signal.SIGTERM), (200, signal.SIGTERM)] + + +@pytest.mark.skipif(os.name != "posix" or not hasattr(os, "fork"), reason="requires POSIX process groups and fork") +def test_terminate_all_force_kills_forked_descendant_after_session_leader_exits(tmp_path: Path) -> None: + child_pid_path = tmp_path / "descendant.pid" + helper = tmp_path / "fork_descendant.py" + helper.write_text( + "\n".join( + ( + "from __future__ import annotations", + "import os", + "import signal", + "import sys", + "from pathlib import Path", + "", + "child_pid_path = Path(sys.argv[1])", + "if os.fork() != 0:", + " os._exit(0)", + "signal.signal(signal.SIGTERM, signal.SIG_IGN)", + "child_pid_path.write_text(str(os.getpid()), encoding='utf-8')", + "while True:", + " signal.pause()", + ) + ) + + "\n", + encoding="utf-8", + ) + + supervisor = ProcessSupervisor() + leader = supervisor.start([sys.executable, str(helper), str(child_pid_path)], os.environ.copy()) + process_group_id = leader.pid + + try: + assert leader.wait(timeout=2) == 0 + deadline = time.monotonic() + 2 + while not child_pid_path.exists() and time.monotonic() < deadline: + time.sleep(0.01) + assert child_pid_path.exists(), "forked descendant did not start" + assert _process_group_exists(process_group_id) + + supervisor.terminate_all(timeout=0.1) + + deadline = time.monotonic() + 2 + while _process_group_exists(process_group_id) and time.monotonic() < deadline: + time.sleep(0.01) + assert not _process_group_exists(process_group_id), "forked descendant survived supervisor cleanup" + finally: + try: + os.killpg(process_group_id, signal.SIGKILL) + except ProcessLookupError: + pass + + +def _process_group_exists(process_group_id: int) -> bool: + try: + os.killpg(process_group_id, 0) + except ProcessLookupError: + return False + return True + + +def test_wait_for_vllm_ready_accepts_authenticated_models_response( + models_server: tuple[ThreadingHTTPServer, str] +) -> None: + _, base_url = models_server + + wait_for_vllm_ready( + base_url=base_url, + api_key="secret-token", + process=DummyProcess([None, None]), + timeout=0.5, + interval=0.01, + ) + + assert ModelsHandler.auth_headers == ["Bearer secret-token"] + + +def test_wait_for_vllm_ready_recovers_from_transient_connection_failures(monkeypatch: pytest.MonkeyPatch) -> None: + port = reserve_tcp_port() + base_url = f"http://127.0.0.1:{port}" + server_ready = threading.Event() + stop_server = threading.Event() + + def delayed_server() -> None: + time.sleep(0.1) + server = ThreadingHTTPServer(("127.0.0.1", port), ModelsHandler) + server.timeout = 0.05 + server_ready.set() + try: + while not stop_server.is_set(): + server.handle_request() + finally: + server.server_close() + + thread = threading.Thread(target=delayed_server, daemon=True) + thread.start() + try: + wait_for_vllm_ready( + base_url=base_url, + api_key=None, + process=DummyProcess([None] * 20), + timeout=1.0, + interval=0.02, + ) + finally: + stop_server.set() + if server_ready.wait(timeout=1): + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + pass + thread.join(timeout=1) + + assert ModelsHandler.requests_seen >= 1 + + +def test_wait_for_vllm_ready_times_out_without_leaking_api_key() -> None: + port = reserve_tcp_port() + + with pytest.raises(TimeoutError, match="timed out waiting for vLLM readiness"): + wait_for_vllm_ready( + base_url=f"http://127.0.0.1:{port}", + api_key="super-secret", + process=DummyProcess([None] * 20), + timeout=0.15, + interval=0.02, + ) + + +def test_wait_for_vllm_ready_retries_request_timeouts_without_leaking_api_key( + models_server: tuple[ThreadingHTTPServer, str] +) -> None: + _, base_url = models_server + ModelsHandler.response_delay_s = 0.2 + + with pytest.raises(TimeoutError) as exc_info: + wait_for_vllm_ready( + base_url=base_url, + api_key="super-secret", + process=DummyProcess([None] * 20), + timeout=0.2, + interval=0.02, + ) + + assert "super-secret" not in str(exc_info.value) + + +def test_wait_for_vllm_ready_reports_backend_exit_without_leaking_api_key() -> None: + port = reserve_tcp_port() + + with pytest.raises(RuntimeError, match="exited with status 17") as exc_info: + wait_for_vllm_ready( + base_url=f"http://127.0.0.1:{port}", + api_key="super-secret", + process=DummyProcess([None, 17]), + timeout=0.5, + interval=0.02, + ) + + assert "super-secret" not in str(exc_info.value) + + +def test_wait_for_vllm_ready_raises_shutdown_requested_before_reporting_child_exit() -> None: + port = reserve_tcp_port() + + with pytest.raises(ShutdownRequested): + wait_for_vllm_ready( + base_url=f"http://127.0.0.1:{port}", + api_key="super-secret", + process=DummyProcess([17]), + timeout=0.5, + interval=0.02, + shutdown_requested=lambda: True, + ) diff --git a/tests/python/test_release_version.py b/tests/python/test_release_version.py new file mode 100644 index 00000000..00cac8eb --- /dev/null +++ b/tests/python/test_release_version.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +VALIDATOR = REPO_ROOT / "scripts" / "validate-python-release-version.sh" +RELEASE_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "release-python.yml" +PYTHON_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "python.yml" +BUILD_CONSTRAINTS = REPO_ROOT / "python-build-constraints.txt" + + +def test_release_version_validator_accepts_build_only_version() -> None: + env = os.environ.copy() + env["AGENTIC_API_RELEASE_VERSION"] = "0.4.0" + + result = subprocess.run(["/bin/bash", str(VALIDATOR)], env=env, capture_output=True, text=True, check=False) + + assert result.returncode == 0, result.stderr + + +def test_release_version_validator_rejects_shell_payload_without_executing_it(tmp_path: Path) -> None: + marker = tmp_path / "injected" + env = os.environ.copy() + env["AGENTIC_API_RELEASE_VERSION"] = f"0.4.0; touch {marker}" + + result = subprocess.run(["/bin/bash", str(VALIDATOR)], env=env, capture_output=True, text=True, check=False) + + assert result.returncode != 0 + assert not marker.exists() + assert "0.4.0 build-only workflow" in result.stderr + + +def test_release_workflow_keeps_dispatch_version_out_of_shell_source() -> None: + workflow = RELEASE_WORKFLOW.read_text(encoding="utf-8") + + assert "AGENTIC_API_RELEASE_VERSION: ${{ inputs.version }}" in workflow + run_blocks = _workflow_run_blocks(workflow) + assert run_blocks + assert all("${{ inputs.version }}" not in block for block in run_blocks) + + +def test_python_workflows_pin_build_tools_and_manylinux_artifact_contract() -> None: + release_workflow = RELEASE_WORKFLOW.read_text(encoding="utf-8") + python_workflow = PYTHON_WORKFLOW.read_text(encoding="utf-8") + constraints = BUILD_CONSTRAINTS.read_text(encoding="utf-8") + + assert "maturin==1.14.1" in constraints + assert "pytest==9.1.1" in constraints + assert "uv==0.11.21" in constraints + assert "PyO3/maturin-action@86b9d133d34bc1b40018696f782949dac11bd380" in release_workflow + assert ( + "quay.io/pypa/manylinux2014_x86_64@" + "sha256:95440e0e72dd3a81dc8d2cf59a84d57af661456620f5bc821ff92048d0e54ff9" + ) in release_workflow + assert "manylinux: \"2014\"" in release_workflow + assert "wheel-tag: py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64" in release_workflow + assert "args: --release --locked" in release_workflow + assert 'uv pip install --python .venv/bin/python "$wheel_path"' in release_workflow + assert 'AGENTIC_API_TEST_WHEEL="$wheel_path" .venv/bin/python -m pytest tests/python -q' in release_workflow + assert "AGENTIC_API_CHECK_PYTHON=.venv/bin/python" in release_workflow + assert "AGENTIC_API_CHECK_SCRIPTS_DIR=.venv/bin" in release_workflow + assert "hashFiles('Cargo.lock', 'python-build-constraints.txt')" in release_workflow + assert "hashFiles('Cargo.lock', 'python-build-constraints.txt')" in python_workflow + + +def _workflow_run_blocks(workflow: str) -> list[str]: + lines = workflow.splitlines() + blocks: list[str] = [] + for index, line in enumerate(lines): + stripped = line.lstrip() + if not stripped.startswith("run:"): + continue + + indent = len(line) - len(stripped) + block = [stripped.removeprefix("run:").strip()] + for candidate in lines[index + 1 :]: + candidate_stripped = candidate.lstrip() + candidate_indent = len(candidate) - len(candidate_stripped) + if candidate_stripped and candidate_indent <= indent: + break + block.append(candidate) + blocks.append("\n".join(block)) + return blocks diff --git a/tests/python/test_version.py b/tests/python/test_version.py new file mode 100644 index 00000000..43fef57b --- /dev/null +++ b/tests/python/test_version.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from importlib.metadata import PackageNotFoundError +from pathlib import Path + +import pytest + +from agentic_api import __version__ +from agentic_api.compatibility import SUPPORTED_VLLM_VERSION +from agentic_api.version import version_report + + +def test_version_report_includes_package_rust_and_vllm_metadata( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + binary = tmp_path / "agentic-server" + binary.write_text("#!/bin/sh\nprintf 'agentic-server 0.4.0\\n'\n") + binary.chmod(0o755) + + monkeypatch.setattr("agentic_api.version.find_packaged_binary", lambda name: binary) + monkeypatch.setattr( + "agentic_api.version.metadata_version", + lambda name: (_ for _ in ()).throw(PackageNotFoundError(name)), + ) + + report = version_report() + + assert f"agentic-api version: {__version__}" in report + assert "Rust binary version: agentic-server 0.4.0" in report + assert f"Supported vLLM version: {SUPPORTED_VLLM_VERSION}" in report + assert "Installed vLLM version: not installed" in report diff --git a/tests/python/test_wheel_check.py b/tests/python/test_wheel_check.py new file mode 100644 index 00000000..7a970443 --- /dev/null +++ b/tests/python/test_wheel_check.py @@ -0,0 +1,279 @@ +from __future__ import annotations + +import json +import os +import stat +import subprocess +import sys +import textwrap +import zipfile +from collections.abc import Mapping +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +CHECK_SCRIPT = REPO_ROOT / "scripts" / "check-python-wheel.sh" +EXPECTED_VERSION = "0.4.0" + + +def test_check_python_wheel_accepts_expected_wheel_and_installed_environment(tmp_path: Path) -> None: + wheel_path = _write_fake_wheel(tmp_path / "agentic_api-0.4.0-py3-none-any.whl") + cargo_metadata_path = _write_fake_cargo_metadata(tmp_path / "cargo-metadata.json") + site_packages = _write_fake_site_packages(tmp_path / "site-packages") + scripts_dir = _write_fake_scripts(tmp_path / "bin") + + result = _run_check_script(wheel_path, site_packages, scripts_dir, cargo_metadata_path=cargo_metadata_path) + + assert result.returncode == 0, result.stderr + assert "wheel validation passed" in result.stdout + + +def test_check_python_wheel_rejects_vllm_payloads(tmp_path: Path) -> None: + wheel_path = _write_fake_wheel( + tmp_path / "agentic_api-0.4.0-py3-none-any.whl", + extra_entries={"agentic_api-0.4.0.data/purelib/vllm/__init__.py": "# forbidden\n"}, + ) + cargo_metadata_path = _write_fake_cargo_metadata(tmp_path / "cargo-metadata.json") + site_packages = _write_fake_site_packages(tmp_path / "site-packages") + scripts_dir = _write_fake_scripts(tmp_path / "bin") + + result = _run_check_script(wheel_path, site_packages, scripts_dir, cargo_metadata_path=cargo_metadata_path) + + assert result.returncode != 0 + assert "forbidden wheel payload" in result.stderr + assert "vllm/__init__.py" in result.stderr + + +def test_check_python_wheel_rejects_nvidia_payloads(tmp_path: Path) -> None: + wheel_path = _write_fake_wheel( + tmp_path / "agentic_api-0.4.0-py3-none-any.whl", + extra_entries={ + "agentic_api-0.4.0.data/purelib/nvidia/cublas/__init__.py": "# forbidden\n", + "agentic_api.libs/libcublas.so.12": "", + "agentic_api.libs/libnccl.so.2": "", + }, + ) + cargo_metadata_path = _write_fake_cargo_metadata(tmp_path / "cargo-metadata.json") + site_packages = _write_fake_site_packages(tmp_path / "site-packages") + scripts_dir = _write_fake_scripts(tmp_path / "bin") + + result = _run_check_script(wheel_path, site_packages, scripts_dir, cargo_metadata_path=cargo_metadata_path) + + assert result.returncode != 0 + assert "forbidden wheel payload" in result.stderr + assert ( + "nvidia/cublas/__init__.py" in result.stderr + or "libcublas.so.12" in result.stderr + or "libnccl.so.2" in result.stderr + ) + + +def test_check_python_wheel_rejects_amd_payloads(tmp_path: Path) -> None: + wheel_path = _write_fake_wheel( + tmp_path / "agentic_api-0.4.0-py3-none-any.whl", + extra_entries={"agentic_api.libs/libamdhip64.so": ""}, + ) + cargo_metadata_path = _write_fake_cargo_metadata(tmp_path / "cargo-metadata.json") + site_packages = _write_fake_site_packages(tmp_path / "site-packages") + scripts_dir = _write_fake_scripts(tmp_path / "bin") + + result = _run_check_script(wheel_path, site_packages, scripts_dir, cargo_metadata_path=cargo_metadata_path) + + assert result.returncode != 0 + assert "forbidden wheel payload" in result.stderr + assert "libamdhip64.so" in result.stderr + + +def test_check_python_wheel_rejects_workspace_version_mismatch(tmp_path: Path) -> None: + wheel_path = _write_fake_wheel(tmp_path / "agentic_api-0.4.0-py3-none-any.whl") + cargo_metadata_path = _write_fake_cargo_metadata( + tmp_path / "cargo-metadata.json", + package_versions={"agentic-server-core": "0.3.0"}, + ) + site_packages = _write_fake_site_packages(tmp_path / "site-packages") + scripts_dir = _write_fake_scripts(tmp_path / "bin") + + result = _run_check_script(wheel_path, site_packages, scripts_dir, cargo_metadata_path=cargo_metadata_path) + + assert result.returncode != 0 + assert "workspace package agentic-server-core version must be 0.4.0" in result.stderr + + +def test_check_python_wheel_rejects_an_unexpected_platform_tag(tmp_path: Path) -> None: + wheel_path = _write_fake_wheel(tmp_path / "agentic_api-0.4.0-py3-none-linux_x86_64.whl") + cargo_metadata_path = _write_fake_cargo_metadata(tmp_path / "cargo-metadata.json") + site_packages = _write_fake_site_packages(tmp_path / "site-packages") + scripts_dir = _write_fake_scripts(tmp_path / "bin") + + result = _run_check_script( + wheel_path, + site_packages, + scripts_dir, + cargo_metadata_path=cargo_metadata_path, + expected_wheel_tag="py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64", + ) + + assert result.returncode != 0 + assert "wheel tag must be exactly" in result.stderr + + +def test_check_python_wheel_validates_workspace_versions_outside_repository_cwd(tmp_path: Path) -> None: + wheel_path = _write_fake_wheel(tmp_path / "agentic_api-0.4.0-py3-none-any.whl") + site_packages = _write_fake_site_packages(tmp_path / "site-packages") + scripts_dir = _write_fake_scripts(tmp_path / "bin") + outside_repo = tmp_path / "outside-repo" + outside_repo.mkdir() + + result = _run_check_script(wheel_path, site_packages, scripts_dir, cwd=outside_repo) + + assert result.returncode == 0, result.stderr + assert "wheel validation passed" in result.stdout + + +def _run_check_script( + wheel_path: Path, + site_packages: Path, + scripts_dir: Path, + *, + cargo_metadata_path: Path | None = None, + expected_wheel_tag: str | None = None, + cwd: Path = REPO_ROOT, +) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env["AGENTIC_API_CHECK_PYTHON"] = sys.executable + env["AGENTIC_API_CHECK_SCRIPTS_DIR"] = str(scripts_dir) + env["AGENTIC_API_EXPECTED_VERSION"] = EXPECTED_VERSION + env["PYTHONPATH"] = str(site_packages) + if cargo_metadata_path is not None: + env["AGENTIC_API_CHECK_CARGO_METADATA_JSON"] = str(cargo_metadata_path) + if expected_wheel_tag is not None: + env["AGENTIC_API_EXPECTED_WHEEL_TAG"] = expected_wheel_tag + + return subprocess.run( + [str(CHECK_SCRIPT), str(wheel_path)], + cwd=cwd, + env=env, + capture_output=True, + text=True, + check=False, + ) + + +def _write_fake_wheel(path: Path, extra_entries: dict[str, str] | None = None) -> Path: + entries = { + "agentic_api/__init__.py": "__version__ = '0.4.0'\n", + "agentic_api-0.4.0.dist-info/METADATA": textwrap.dedent( + """\ + Metadata-Version: 2.3 + Name: agentic-api + Version: 0.4.0 + """ + ), + "agentic_api-0.4.0.dist-info/entry_points.txt": textwrap.dedent( + """\ + [console_scripts] + agentic-api = agentic_api.cli:main + """ + ), + "agentic_api-0.4.0.data/scripts/agentic": "", + "agentic_api-0.4.0.data/scripts/agentic-server": "", + } + if extra_entries is not None: + entries.update(extra_entries) + + with zipfile.ZipFile(path, "w") as archive: + for name, content in entries.items(): + archive.writestr(name, content) + return path + + +def _write_fake_site_packages(path: Path) -> Path: + package_dir = path / "agentic_api" + package_dir.mkdir(parents=True) + (package_dir / "__init__.py").write_text("__version__ = '0.4.0'\n", encoding="utf-8") + + dist_info = path / "agentic_api-0.4.0.dist-info" + dist_info.mkdir() + (dist_info / "METADATA").write_text( + textwrap.dedent( + """\ + Metadata-Version: 2.3 + Name: agentic-api + Version: 0.4.0 + """ + ), + encoding="utf-8", + ) + return path + + +def _write_fake_cargo_metadata( + path: Path, + *, + package_versions: Mapping[str, str] | None = None, +) -> Path: + workspace_names = ("agentic-praxis", "agentic-server-core", "agentic-server") + version_overrides = dict(package_versions or {}) + packages = [] + workspace_members = [] + + for name in workspace_names: + version = version_overrides.get(name, EXPECTED_VERSION) + package_id = f"path+file:///workspace/{name}#{version}" + packages.append({"name": name, "version": version, "id": package_id}) + workspace_members.append(package_id) + + path.write_text( + json.dumps({"packages": packages, "workspace_members": workspace_members}), + encoding="utf-8", + ) + return path + + +def _write_fake_scripts(path: Path) -> Path: + path.mkdir(parents=True) + _write_executable( + path / "agentic", + """ + #!/usr/bin/env python3 + import sys + + if sys.argv[1:] == ["--version"]: + print("agentic 0.4.0") + raise SystemExit(0) + raise SystemExit(1) + """, + ) + _write_executable( + path / "agentic-server", + """ + #!/usr/bin/env python3 + import sys + + if sys.argv[1:] == ["--version"]: + print("agentic-server 0.4.0") + raise SystemExit(0) + raise SystemExit(1) + """, + ) + _write_executable( + path / "agentic-api", + """ + #!/usr/bin/env python3 + import sys + + if sys.argv[1:] == ["version"]: + print("agentic-api version: 0.4.0") + print("Rust binary version: agentic-server 0.4.0") + print("Supported vLLM version: 0.11.0") + print("Installed vLLM version: not installed") + raise SystemExit(0) + raise SystemExit(1) + """, + ) + return path + + +def _write_executable(path: Path, source: str) -> None: + path.write_text(textwrap.dedent(source).lstrip(), encoding="utf-8") + path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) From e51905740a53680cc65fa2e5c1d8e4727dafe9c3 Mon Sep 17 00:00:00 2001 From: Francisco Javier Arceo Date: Mon, 24 Aug 2026 09:48:58 -0400 Subject: [PATCH 02/18] fix: stabilize Python CI matrix Install the Rust components required by the repository toolchain during wheel builds and keep the cross-platform process test from mutating os.name globally, which was breaking pytest pathlib cleanup on Linux. Signed-off-by: Francisco Javier Arceo --- .github/workflows/python.yml | 1 + python/agentic_api/process.py | 5 +++-- tests/python/test_process.py | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 3f1c4058..523fe283 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -64,6 +64,7 @@ jobs: uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 with: toolchain: 1.98.0 + components: clippy, rustfmt - name: Cache cargo registry and build uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v4 diff --git a/python/agentic_api/process.py b/python/agentic_api/process.py index 77616e69..3064a81f 100644 --- a/python/agentic_api/process.py +++ b/python/agentic_api/process.py @@ -15,6 +15,7 @@ POLL_INTERVAL_S = 0.05 READY_REQUEST_TIMEOUT_S = 0.1 +_IS_POSIX = os.name == "posix" class ShutdownRequested(RuntimeError): @@ -60,7 +61,7 @@ def start(self, command: Sequence[str], env: Mapping[str, str]) -> subprocess.Po "env": dict(env), "shell": False, } - if os.name == "posix": + if _IS_POSIX: popen_kwargs["start_new_session"] = True process = subprocess.Popen(command_list, **popen_kwargs) @@ -68,7 +69,7 @@ def start(self, command: Sequence[str], env: Mapping[str, str]) -> subprocess.Po name=Path(command_list[0]).name or command_list[0], command=tuple(command_list), process=process, - process_group_id=process.pid if os.name == "posix" else None, + process_group_id=process.pid if _IS_POSIX else None, ) with self._lock: self._children.append(child) diff --git a/tests/python/test_process.py b/tests/python/test_process.py index f84bdac7..5fed8731 100644 --- a/tests/python/test_process.py +++ b/tests/python/test_process.py @@ -220,7 +220,7 @@ def test_terminate_all_uses_direct_child_signals_off_posix(monkeypatch: pytest.M fake_process.wait_should_timeout = True monkeypatch.setattr("agentic_api.process.subprocess.Popen", lambda command, **kwargs: fake_process) - monkeypatch.setattr("agentic_api.process.os.name", "nt") + monkeypatch.setattr("agentic_api.process._IS_POSIX", False) supervisor = ProcessSupervisor() supervisor.start(["agentic-server"], {}) From aa1463e18b5415a0c7f52e801cade2b259dd8863 Mon Sep 17 00:00:00 2001 From: Francisco Javier Arceo Date: Wed, 26 Aug 2026 07:09:56 -0400 Subject: [PATCH 03/18] feat: harden Python CLI developer experience Add standard version and machine-readable doctor commands, return actionable errors for missing binaries, reject malformed or credential-bearing upstream URLs, document the local vLLM extra, and exercise source installation through the end-to-end test. Signed-off-by: Francisco Javier Arceo --- README.md | 12 +++-- docs/guides/python-installation.md | 5 +- python/agentic_api/cli.py | 32 ++++++++++-- python/agentic_api/diagnostics.py | 14 ++++-- scripts/tests/agentic-cli-e2e-test.py | 71 +++++++++++++++++++++++++++ tests/python/test_cli.py | 46 +++++++++++++++++ tests/python/test_diagnostics.py | 26 ++++++++++ 7 files changed, 191 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index e75f4958..4eb33a40 100644 --- a/README.md +++ b/README.md @@ -122,12 +122,12 @@ permission checks and disables Codex approvals and sandboxing. ### Python distribution -The `agentic-api` wheel packages the Rust gateway and a small Python launcher. Version 0.4.0 is a build-only release: -download the wheel artifact for your platform from the release workflow, then install that local file. It is not +The `agentic-api` wheel packages the Rust gateway and a small Python launcher. This release includes build-only wheel +artifacts: download the wheel for your platform from the release workflow, then install that local file. It is not published on PyPI. ```bash -WHEEL_PATH=/absolute/path/to/agentic_api-0.4.0-PLATFORM.whl +WHEEL_PATH=/absolute/path/to/agentic_api-PLATFORM.whl uv pip install "$WHEEL_PATH" agentic-api serve --vllm-base-url http://existing-vllm:8000 @@ -135,7 +135,11 @@ uv pip install "agentic-api[local] @ file://$WHEEL_PATH" agentic-api serve --model MODEL_ID ``` -The `[local]` extra is for supported Linux hosts where the launcher should manage a local vLLM process. +The base install is for remote mode and does not install vLLM. The `[local]` extra installs the pinned vLLM runtime so +the launcher can manage a local vLLM process on supported Linux hosts. + +Use `agentic-api --version` for a quick install check and `agentic-api doctor --mode remote --json` when an agent or +script needs machine-readable diagnostics. #### Planned for 0.5.0 diff --git a/docs/guides/python-installation.md b/docs/guides/python-installation.md index 70fc550a..648c0647 100644 --- a/docs/guides/python-installation.md +++ b/docs/guides/python-installation.md @@ -36,8 +36,8 @@ uv pip install "agentic-api[local] @ file://$WHEEL_PATH" agentic-api serve --model Qwen/Qwen3-30B-A3B-FP8 ``` -The launcher still accepts arbitrary `--model` values. The `[local]` extra just supplies the tested vLLM dependency -and makes the managed-vLLM workflow available. +The launcher still accepts arbitrary `--model` values. The base package does not install vLLM; the `[local]` extra +supplies the tested vLLM dependency and makes the managed-vLLM workflow available. Managed vLLM supports passthrough arguments after `--`: @@ -68,6 +68,7 @@ whether the current mode is healthy. agentic-api doctor agentic-api doctor --mode remote agentic-api doctor --mode local +agentic-api doctor --mode remote --json ``` Use `--mode remote` when you only need the packaged Rust gateway checks. Use `--mode local` when you want to verify the diff --git a/python/agentic_api/cli.py b/python/agentic_api/cli.py index 10d5594b..9fe9223d 100644 --- a/python/agentic_api/cli.py +++ b/python/agentic_api/cli.py @@ -8,6 +8,7 @@ from typing import Sequence from urllib.parse import urlparse +from agentic_api import __version__ from agentic_api.diagnostics import doctor from agentic_api.version import version_report @@ -52,6 +53,7 @@ def parse_args(self, args: Sequence[str] | None = None, namespace: argparse.Name def build_parser() -> argparse.ArgumentParser: parser = _AgenticArgumentParser(prog="agentic-api", description="Python launcher for packaged Agentic API binaries") + parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") subparsers = parser.add_subparsers(dest="command", required=True) serve_parser = subparsers.add_parser("serve", help="Launch Agentic API in local or remote mode") @@ -68,6 +70,9 @@ def build_parser() -> argparse.ArgumentParser: doctor_parser = subparsers.add_parser("doctor", help="Report packaged binary and compatibility diagnostics") doctor_parser.add_argument("--mode", choices=("local", "remote")) + doctor_parser.add_argument( + "--json", action="store_true", dest="json_output", help="Emit a machine-readable JSON report" + ) subparsers.add_parser("version", help="Print package and packaged binary versions") return parser @@ -82,11 +87,15 @@ def main(argv: Sequence[str] | None = None) -> int: return code if isinstance(code, int) else 1 if namespace.command == "version": - print(version_report()) - return 0 + try: + print(version_report()) + return 0 + except (FileNotFoundError, RuntimeError) as error: + print(str(error), file=sys.stderr) + return 1 if namespace.command == "doctor": - return doctor(namespace.mode) + return doctor(namespace.mode, json_output=namespace.json_output) if namespace.command == "serve": try: @@ -171,9 +180,22 @@ def _bounded_timeout(value: str) -> float: def _normalize_base_url(parser: argparse.ArgumentParser, value: str) -> str: - parsed = urlparse(value) - if parsed.scheme not in {"http", "https"} or not parsed.netloc: + if any(character.isspace() for character in value): + parser.error("--vllm-base-url must not contain whitespace") + + try: + parsed = urlparse(value) + hostname = parsed.hostname + port = parsed.port + except ValueError as error: + parser.error(f"--vllm-base-url is malformed: {error}") + + if parsed.scheme not in {"http", "https"} or not parsed.netloc or not hostname: parser.error("--vllm-base-url must be an http:// or https:// base URL") + if parsed.username is not None or parsed.password is not None: + parser.error("--vllm-base-url must not contain credentials; use an environment variable for API keys") + if port is not None and not 1 <= port <= 65_535: + parser.error("--vllm-base-url port must be between 1 and 65535") if parsed.query or parsed.fragment: parser.error("--vllm-base-url must not include a query string or fragment") return value.rstrip("/") diff --git a/python/agentic_api/diagnostics.py b/python/agentic_api/diagnostics.py index 52cc2245..5b542f62 100644 --- a/python/agentic_api/diagnostics.py +++ b/python/agentic_api/diagnostics.py @@ -1,8 +1,9 @@ from __future__ import annotations +import json import os import platform -from dataclasses import dataclass +from dataclasses import asdict, dataclass from importlib.metadata import PackageNotFoundError, version as metadata_version from pathlib import Path @@ -33,9 +34,9 @@ class DoctorReport: remote_message: str -def doctor(mode: str | None) -> int: +def doctor(mode: str | None, *, json_output: bool = False) -> int: report = collect_doctor_report() - print(render_doctor_report(report, mode)) + print(render_doctor_report(report, mode, json_output=json_output)) if mode == "local": return 0 if report.local_ok else 1 @@ -71,7 +72,12 @@ def collect_doctor_report() -> DoctorReport: ) -def render_doctor_report(report: DoctorReport, mode: str | None) -> str: +def render_doctor_report(report: DoctorReport, mode: str | None, *, json_output: bool = False) -> str: + if json_output: + payload = asdict(report) + payload["selected_mode"] = mode or "all" + return json.dumps(payload, sort_keys=True) + local_health = "ok" if report.local_ok else "unavailable" remote_health = "ok" if report.remote_ok else "unavailable" diff --git a/scripts/tests/agentic-cli-e2e-test.py b/scripts/tests/agentic-cli-e2e-test.py index 9d7999c3..f1e802f4 100755 --- a/scripts/tests/agentic-cli-e2e-test.py +++ b/scripts/tests/agentic-cli-e2e-test.py @@ -174,6 +174,76 @@ def run_harness(agentic: Path, mode: str, upstream: str, temp: Path, harness: Pa assert result.read_text() == f"{mode} passed\n" +def run_python_source_install(repo: Path, temp: Path) -> None: + """Install the Python package from this checkout and exercise its public CLI.""" + + environment = temp / "python-source-environment" + completed = subprocess.run( + [sys.executable, "-m", "venv", str(environment)], + capture_output=True, + text=True, + timeout=30, + ) + if completed.returncode != 0: + raise AssertionError(f"creating Python E2E environment failed\n{completed.stdout}\n{completed.stderr}") + + python = environment / "bin" / "python" + cli = environment / "bin" / "agentic-api" + install_package = subprocess.run( + [ + str(python), + "-m", + "pip", + "install", + "--disable-pip-version-check", + "--no-deps", + "--constraint", + str(repo / "python-build-constraints.txt"), + str(repo), + ], + capture_output=True, + text=True, + timeout=300, + ) + if install_package.returncode != 0: + raise AssertionError( + f"installing agentic-api from source failed\n{install_package.stdout}\n{install_package.stderr}" + ) + + import_check = subprocess.run( + [str(python), "-c", "import agentic_api; assert agentic_api.__version__ == '0.4.0'"], + capture_output=True, + text=True, + timeout=30, + ) + if import_check.returncode != 0: + raise AssertionError( + f"source-installed agentic_api import failed\n{import_check.stdout}\n{import_check.stderr}" + ) + + version_check = subprocess.run( + [str(cli), "--version"], + capture_output=True, + text=True, + timeout=30, + ) + if version_check.returncode != 0 or version_check.stdout.strip() != "agentic-api 0.4.0": + raise AssertionError( + f"source-installed agentic-api --version failed\n{version_check.stdout}\n{version_check.stderr}" + ) + + doctor_check = subprocess.run( + [str(cli), "doctor", "--mode", "remote"], + capture_output=True, + text=True, + timeout=30, + ) + if doctor_check.returncode != 0 or "Remote mode health: ok" not in doctor_check.stdout: + raise AssertionError( + f"source-installed agentic-api doctor failed\n{doctor_check.stdout}\n{doctor_check.stderr}" + ) + + def main() -> None: repo = Path(__file__).resolve().parents[2] agentic = Path(os.environ.get("AGENTIC_BIN", repo / "target/debug/agentic")) @@ -181,6 +251,7 @@ def main() -> None: raise SystemExit(f"missing {agentic}; run cargo build --bins first") with tempfile.TemporaryDirectory(prefix="agentic-cli-e2e-") as directory: temp = Path(directory) + run_python_source_install(repo, temp) harness = temp / "fake-harness" harness.write_text(HARNESS) harness.chmod(0o755) diff --git a/tests/python/test_cli.py b/tests/python/test_cli.py index 80bcbb60..1c1020ed 100644 --- a/tests/python/test_cli.py +++ b/tests/python/test_cli.py @@ -41,6 +41,23 @@ def test_serve_with_vllm_base_url_uses_remote_mode() -> None: assert options.vllm_base_url == "http://existing-vllm:8000" +@pytest.mark.parametrize( + "url", + [ + "http://user:password@existing-vllm:8000", + "http://existing-vllm:99999", + "http://[invalid", + "http://existing vllm:8000", + ], +) +def test_serve_rejects_unsafe_or_malformed_remote_base_urls(url: str, capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as exc_info: + build_parser().parse_args(["serve", "--vllm-base-url", url]) + + assert exc_info.value.code == 2 + assert "--vllm-base-url" in capsys.readouterr().err + + @pytest.mark.parametrize( ("args", "message"), [ @@ -183,3 +200,32 @@ def test_version_subcommand_exits_successfully( assert exit_code == 0 assert capsys.readouterr().out == "version report\n" + + +def test_top_level_version_flag_prints_package_version(capsys: pytest.CaptureFixture[str]) -> None: + exit_code = main(["--version"]) + + assert exit_code == 0 + assert capsys.readouterr().out == "agentic-api 0.4.0\n" + + +def test_version_subcommand_reports_missing_binary_without_traceback( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr( + "agentic_api.cli.version_report", + lambda: (_ for _ in ()).throw(FileNotFoundError("agentic-server not found")), + ) + + exit_code = main(["version"]) + + assert exit_code == 1 + assert capsys.readouterr().err == "agentic-server not found\n" + + +def test_doctor_json_flag_is_forwarded_to_diagnostics(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[tuple[str | None, bool]] = [] + monkeypatch.setattr("agentic_api.cli.doctor", lambda mode, *, json_output: calls.append((mode, json_output)) or 0) + + assert main(["doctor", "--mode", "remote", "--json"]) == 0 + assert calls == [("remote", True)] diff --git a/tests/python/test_diagnostics.py b/tests/python/test_diagnostics.py index 860b609b..291c894a 100644 --- a/tests/python/test_diagnostics.py +++ b/tests/python/test_diagnostics.py @@ -1,6 +1,7 @@ from __future__ import annotations import importlib +import json from pathlib import Path import pytest @@ -151,6 +152,31 @@ def test_python_module_entrypoint_delegates_to_cli_main(monkeypatch: pytest.Monk assert called == [None] +def test_doctor_json_is_machine_readable_and_preserves_exit_status( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path +) -> None: + rust_binary = tmp_path / "agentic-server" + rust_binary.write_text("") + rust_binary.chmod(0o755) + + monkeypatch.setattr("agentic_api.diagnostics.find_packaged_binary", lambda name: rust_binary) + monkeypatch.setattr("agentic_api.diagnostics.read_binary_version", lambda path: "agentic-server 0.4.0") + monkeypatch.setattr("agentic_api.diagnostics.metadata_version", _metadata_version_without_vllm) + monkeypatch.setattr( + "agentic_api.diagnostics.find_active_environment_executable", + lambda name: (_ for _ in ()).throw(FileNotFoundError(name)), + ) + + exit_code = diagnostics.doctor("remote", json_output=True) + payload = json.loads(capsys.readouterr().out) + + assert exit_code == 0 + assert payload["selected_mode"] == "remote" + assert payload["remote_ok"] is True + assert payload["local_ok"] is False + assert payload["package_version"] == "0.4.0" + + def _metadata_version_without_vllm(name: str) -> str: if name == "agentic-api": return "0.4.0" From 6d4613bbb7d4c5196e799cafc8f4fc8c5371716c Mon Sep 17 00:00:00 2001 From: Francisco Javier Arceo Date: Wed, 26 Aug 2026 07:11:20 -0400 Subject: [PATCH 04/18] ci: avoid oversized Python target cache Keep Cargo registry caches for Python wheel jobs but do not restore or save the large compiled target directory across Python matrix entries. Signed-off-by: Francisco Javier Arceo --- .github/workflows/python.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 523fe283..d3e10e53 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -72,7 +72,6 @@ jobs: path: | ~/.cargo/registry ~/.cargo/git - target key: cargo-python-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('Cargo.lock', 'python-build-constraints.txt') }} restore-keys: | cargo-python-${{ runner.os }}-${{ matrix.python-version }}- From 5a3db2d73ca7383d8235861b34b536f2f925b7cb Mon Sep 17 00:00:00 2001 From: Francisco Javier Arceo Date: Wed, 26 Aug 2026 07:24:09 -0400 Subject: [PATCH 05/18] fix: align Python release checks with workspace version Signed-off-by: Francisco Javier Arceo --- .github/workflows/python.yml | 11 +++- .github/workflows/release-python.yml | 2 +- crates/agentic-server/tests/cli_test.rs | 2 +- docs/guides/python-installation.md | 8 +-- python/agentic_api/diagnostics.py | 5 +- scripts/tests/agentic-cli-e2e-test.py | 19 ++++-- scripts/validate-python-release-version.sh | 15 ++++- tests/python/test_cli.py | 3 +- tests/python/test_diagnostics.py | 4 +- tests/python/test_docs_examples.py | 2 +- tests/python/test_metadata.py | 6 +- tests/python/test_release_version.py | 10 ++- tests/python/test_wheel_check.py | 74 ++++++++++++++-------- 13 files changed, 108 insertions(+), 53 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index d3e10e53..9aea2f9f 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -82,20 +82,25 @@ jobs: run: python -m pip install --constraint python-build-constraints.txt uv - name: Build and validate wheel - env: - AGENTIC_API_EXPECTED_VERSION: "0.4.0" run: | set -euo pipefail + expected_version="$(cargo metadata --format-version 1 --no-deps \ + | jq -r '.packages[] | select(.name == "agentic-server") | .version')" + if [ -z "$expected_version" ] || [ "$expected_version" = "null" ]; then + echo "::error::agentic-server workspace version could not be determined" + exit 1 + fi uv venv --python "${{ matrix.python-version }}" .venv uv pip install --python .venv/bin/python --constraint python-build-constraints.txt maturin pytest wheel_dir="$RUNNER_TEMP/agentic-api-wheels-${{ matrix.python-version }}" mkdir -p "$wheel_dir" .venv/bin/python -m maturin build --release --locked --out "$wheel_dir" wheel_path="$( - .venv/bin/python -c 'from pathlib import Path; import sys; matches = sorted(Path(sys.argv[1]).glob("agentic_api-0.4.0-*.whl")); assert len(matches) == 1, f"expected exactly one wheel, found {[path.name for path in matches]}"; print(matches[0])' "$wheel_dir" + .venv/bin/python -c 'from pathlib import Path; import sys; matches = sorted(Path(sys.argv[1]).glob(f"agentic_api-{sys.argv[2]}-*.whl")); assert len(matches) == 1, f"expected exactly one wheel, found {[path.name for path in matches]}"; print(matches[0])' "$wheel_dir" "$expected_version" )" uv pip install --python .venv/bin/python "$wheel_path" AGENTIC_API_TEST_WHEEL="$wheel_path" .venv/bin/python -m pytest tests/python -q AGENTIC_API_CHECK_PYTHON=.venv/bin/python \ AGENTIC_API_CHECK_SCRIPTS_DIR=.venv/bin \ + AGENTIC_API_EXPECTED_VERSION="$expected_version" \ scripts/check-python-wheel.sh "$wheel_path" diff --git a/.github/workflows/release-python.yml b/.github/workflows/release-python.yml index 693d5bf9..3c57ce3c 100644 --- a/.github/workflows/release-python.yml +++ b/.github/workflows/release-python.yml @@ -9,7 +9,7 @@ on: description: "Build-only Python release version already merged into main" required: true type: string - default: "0.4.0" + default: "0.5.0" concurrency: group: release-python-${{ inputs.version }} diff --git a/crates/agentic-server/tests/cli_test.rs b/crates/agentic-server/tests/cli_test.rs index 8ded8b69..b2c26f0c 100644 --- a/crates/agentic-server/tests/cli_test.rs +++ b/crates/agentic-server/tests/cli_test.rs @@ -44,7 +44,7 @@ fn agentic_server_reports_version() { assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).expect("stdout must be UTF-8"); - assert!(stdout.contains("agentic-server 0.4.0")); + assert!(stdout.contains(concat!("agentic-server ", env!("CARGO_PKG_VERSION")))); } #[test] diff --git a/docs/guides/python-installation.md b/docs/guides/python-installation.md index 648c0647..53fc486e 100644 --- a/docs/guides/python-installation.md +++ b/docs/guides/python-installation.md @@ -6,13 +6,13 @@ backend, not part of the Agentic API product name. Use the base wheel when you w The Rust-native `agentic` CLI remains supported for `run codex`, `run claude`, `serve`, and `validate`. -## Install the 0.4.0 artifact +## Install the release artifact -0.4.0 is a build-only release. It produces wheel artifacts for supported platforms but does not publish them to PyPI. +This release produces wheel artifacts for supported platforms but does not publish them to PyPI. Download the wheel for your platform from the release workflow and use its absolute path below: ```bash -WHEEL_PATH=/absolute/path/to/agentic_api-0.4.0-PLATFORM.whl +WHEEL_PATH=/absolute/path/to/agentic_api-PLATFORM.whl ``` ### Install the base package @@ -50,7 +50,7 @@ agentic-api serve --model Qwen/Qwen3-30B-A3B-FP8 -- \ ## Planned for 0.5.0 The following public-index installation and `uvx` commands are planned for 0.5.0. Use them only after the PyPI -publication gate has passed; they do not work for the unpublished 0.4.0 distribution: +publication gate has passed; they do not work until the package is published: ```bash uv pip install agentic-api diff --git a/python/agentic_api/diagnostics.py b/python/agentic_api/diagnostics.py index 5b542f62..634c2f63 100644 --- a/python/agentic_api/diagnostics.py +++ b/python/agentic_api/diagnostics.py @@ -7,6 +7,7 @@ from importlib.metadata import PackageNotFoundError, version as metadata_version from pathlib import Path +from agentic_api import __version__ from agentic_api.binary import ( PackagedBinaryNotFoundError, PackagedBinaryVersionError, @@ -123,8 +124,8 @@ def _rust_binary_details() -> tuple[str, bool, str]: def _local_health(installed_vllm_version: str, vllm_executable_path: str) -> tuple[bool, str]: install_hint = ( - "Install the 0.4.0 wheel artifact with its local extra: " - '`uv pip install "agentic-api[local] @ file:///path/to/agentic_api-0.4.0-PLATFORM.whl"`.' + "Install the agentic-api wheel artifact with its local extra: " + '`uv pip install "agentic-api[local] @ file:///path/to/agentic_api-PLATFORM.whl"`.' ) if installed_vllm_version == "not installed": return (False, install_hint) diff --git a/scripts/tests/agentic-cli-e2e-test.py b/scripts/tests/agentic-cli-e2e-test.py index f1e802f4..4d86ea30 100755 --- a/scripts/tests/agentic-cli-e2e-test.py +++ b/scripts/tests/agentic-cli-e2e-test.py @@ -174,7 +174,7 @@ def run_harness(agentic: Path, mode: str, upstream: str, temp: Path, harness: Pa assert result.read_text() == f"{mode} passed\n" -def run_python_source_install(repo: Path, temp: Path) -> None: +def run_python_source_install(repo: Path, temp: Path, expected_version: str) -> None: """Install the Python package from this checkout and exercise its public CLI.""" environment = temp / "python-source-environment" @@ -211,7 +211,7 @@ def run_python_source_install(repo: Path, temp: Path) -> None: ) import_check = subprocess.run( - [str(python), "-c", "import agentic_api; assert agentic_api.__version__ == '0.4.0'"], + [str(python), "-c", f"import agentic_api; assert agentic_api.__version__ == {expected_version!r}"], capture_output=True, text=True, timeout=30, @@ -227,7 +227,7 @@ def run_python_source_install(repo: Path, temp: Path) -> None: text=True, timeout=30, ) - if version_check.returncode != 0 or version_check.stdout.strip() != "agentic-api 0.4.0": + if version_check.returncode != 0 or version_check.stdout.strip() != f"agentic-api {expected_version}": raise AssertionError( f"source-installed agentic-api --version failed\n{version_check.stdout}\n{version_check.stderr}" ) @@ -249,9 +249,20 @@ def main() -> None: agentic = Path(os.environ.get("AGENTIC_BIN", repo / "target/debug/agentic")) if not agentic.is_file(): raise SystemExit(f"missing {agentic}; run cargo build --bins first") + metadata = subprocess.run( + ["cargo", "metadata", "--format-version", "1", "--no-deps", "--manifest-path", str(repo / "Cargo.toml")], + capture_output=True, + text=True, + check=True, + ) + expected_version = next( + package["version"] + for package in json.loads(metadata.stdout)["packages"] + if package["name"] == "agentic-server" + ) with tempfile.TemporaryDirectory(prefix="agentic-cli-e2e-") as directory: temp = Path(directory) - run_python_source_install(repo, temp) + run_python_source_install(repo, temp, expected_version) harness = temp / "fake-harness" harness.write_text(HARNESS) harness.chmod(0o755) diff --git a/scripts/validate-python-release-version.sh b/scripts/validate-python-release-version.sh index 98dc778d..913f342e 100755 --- a/scripts/validate-python-release-version.sh +++ b/scripts/validate-python-release-version.sh @@ -2,7 +2,18 @@ set -euo pipefail requested_version="${AGENTIC_API_RELEASE_VERSION:-}" -if [[ "$requested_version" != "0.4.0" ]]; then - echo "release-python.yml is a 0.4.0 build-only workflow; other versions are rejected" >&2 +workspace_version="$(awk ' + $0 == "[workspace.package]" { in_section = 1; next } + in_section && /^\[/ { in_section = 0 } + in_section && $1 == "version" { gsub(/"/, "", $3); print $3; exit } +' Cargo.toml)" + +if [[ -z "$workspace_version" ]]; then + echo "unable to determine the Cargo workspace version" >&2 + exit 1 +fi + +if [[ "$requested_version" != "$workspace_version" ]]; then + echo "release-python.yml is a ${workspace_version} build-only workflow; requested version does not match" >&2 exit 1 fi diff --git a/tests/python/test_cli.py b/tests/python/test_cli.py index 1c1020ed..e9d7a002 100644 --- a/tests/python/test_cli.py +++ b/tests/python/test_cli.py @@ -4,6 +4,7 @@ import pytest +from agentic_api import __version__ from agentic_api.cli import ServeOptions, build_parser, main @@ -206,7 +207,7 @@ def test_top_level_version_flag_prints_package_version(capsys: pytest.CaptureFix exit_code = main(["--version"]) assert exit_code == 0 - assert capsys.readouterr().out == "agentic-api 0.4.0\n" + assert capsys.readouterr().out == f"agentic-api {__version__}\n" def test_version_subcommand_reports_missing_binary_without_traceback( diff --git a/tests/python/test_diagnostics.py b/tests/python/test_diagnostics.py index 291c894a..b7b7c9cc 100644 --- a/tests/python/test_diagnostics.py +++ b/tests/python/test_diagnostics.py @@ -56,8 +56,8 @@ def test_local_doctor_reports_missing_vllm_installation( assert exit_code == 1 assert "Selected mode: local" in output assert "Local mode health: unavailable" in output - assert "Install the 0.4.0 wheel artifact with its local extra" in output - assert "file:///path/to/agentic_api-0.4.0-PLATFORM.whl" in output + assert "Install the agentic-api wheel artifact with its local extra" in output + assert "file:///path/to/agentic_api-PLATFORM.whl" in output def test_local_doctor_reports_incompatible_vllm_version( diff --git a/tests/python/test_docs_examples.py b/tests/python/test_docs_examples.py index 1f0455c8..fde3c0d5 100644 --- a/tests/python/test_docs_examples.py +++ b/tests/python/test_docs_examples.py @@ -18,7 +18,7 @@ def test_documented_python_install_commands_respect_release_publication_gate() - assert 'uv pip install "$WHEEL_PATH"' in combined assert 'agentic-api[local] @ file://$WHEEL_PATH' in combined - assert "0.4.0 is a build-only release" in combined + assert "This release produces wheel artifacts" in combined assert "Planned for 0.5.0" in combined assert "after the PyPI publication gate" in combined assert "uv pip install agentic-api" in combined diff --git a/tests/python/test_metadata.py b/tests/python/test_metadata.py index 03afc725..61fc5f0b 100644 --- a/tests/python/test_metadata.py +++ b/tests/python/test_metadata.py @@ -4,10 +4,10 @@ from agentic_api.compatibility import SUPPORTED_VLLM_VERSION -def test_installed_package_version_is_0_4_0() -> None: +def test_installed_package_version_matches_package_constant() -> None: installed_distribution = distribution("agentic-api") - assert installed_distribution.version == "0.4.0" - assert installed_distribution.metadata["Version"] == "0.4.0" + assert installed_distribution.version == __version__ + assert installed_distribution.metadata["Version"] == __version__ assert __version__ == installed_distribution.version diff --git a/tests/python/test_release_version.py b/tests/python/test_release_version.py index 00cac8eb..2c441f1f 100644 --- a/tests/python/test_release_version.py +++ b/tests/python/test_release_version.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import re import subprocess from pathlib import Path @@ -10,11 +11,14 @@ RELEASE_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "release-python.yml" PYTHON_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "python.yml" BUILD_CONSTRAINTS = REPO_ROOT / "python-build-constraints.txt" +WORKSPACE_VERSION = re.search( + r"(?ms)^\[workspace\.package\].*?^version\s*=\s*\"([^\"]+)\"", (REPO_ROOT / "Cargo.toml").read_text() +).group(1) def test_release_version_validator_accepts_build_only_version() -> None: env = os.environ.copy() - env["AGENTIC_API_RELEASE_VERSION"] = "0.4.0" + env["AGENTIC_API_RELEASE_VERSION"] = WORKSPACE_VERSION result = subprocess.run(["/bin/bash", str(VALIDATOR)], env=env, capture_output=True, text=True, check=False) @@ -24,13 +28,13 @@ def test_release_version_validator_accepts_build_only_version() -> None: def test_release_version_validator_rejects_shell_payload_without_executing_it(tmp_path: Path) -> None: marker = tmp_path / "injected" env = os.environ.copy() - env["AGENTIC_API_RELEASE_VERSION"] = f"0.4.0; touch {marker}" + env["AGENTIC_API_RELEASE_VERSION"] = f"{WORKSPACE_VERSION}; touch {marker}" result = subprocess.run(["/bin/bash", str(VALIDATOR)], env=env, capture_output=True, text=True, check=False) assert result.returncode != 0 assert not marker.exists() - assert "0.4.0 build-only workflow" in result.stderr + assert f"{WORKSPACE_VERSION} build-only workflow" in result.stderr def test_release_workflow_keeps_dispatch_version_out_of_shell_source() -> None: diff --git a/tests/python/test_wheel_check.py b/tests/python/test_wheel_check.py index 7a970443..038dcf0c 100644 --- a/tests/python/test_wheel_check.py +++ b/tests/python/test_wheel_check.py @@ -2,6 +2,7 @@ import json import os +import re import stat import subprocess import sys @@ -14,6 +15,10 @@ REPO_ROOT = Path(__file__).resolve().parents[2] CHECK_SCRIPT = REPO_ROOT / "scripts" / "check-python-wheel.sh" EXPECTED_VERSION = "0.4.0" +WORKSPACE_VERSION = re.search( + r'(?ms)^\[workspace\.package\].*?^version\s*=\s*"([^"]+)"', + (REPO_ROOT / "Cargo.toml").read_text(encoding="utf-8"), +).group(1) def test_check_python_wheel_accepts_expected_wheel_and_installed_environment(tmp_path: Path) -> None: @@ -118,13 +123,22 @@ def test_check_python_wheel_rejects_an_unexpected_platform_tag(tmp_path: Path) - def test_check_python_wheel_validates_workspace_versions_outside_repository_cwd(tmp_path: Path) -> None: - wheel_path = _write_fake_wheel(tmp_path / "agentic_api-0.4.0-py3-none-any.whl") - site_packages = _write_fake_site_packages(tmp_path / "site-packages") - scripts_dir = _write_fake_scripts(tmp_path / "bin") + wheel_path = _write_fake_wheel( + tmp_path / f"agentic_api-{WORKSPACE_VERSION}-py3-none-any.whl", + version=WORKSPACE_VERSION, + ) + site_packages = _write_fake_site_packages(tmp_path / "site-packages", version=WORKSPACE_VERSION) + scripts_dir = _write_fake_scripts(tmp_path / "bin", version=WORKSPACE_VERSION) outside_repo = tmp_path / "outside-repo" outside_repo.mkdir() - result = _run_check_script(wheel_path, site_packages, scripts_dir, cwd=outside_repo) + result = _run_check_script( + wheel_path, + site_packages, + scripts_dir, + cwd=outside_repo, + expected_version=WORKSPACE_VERSION, + ) assert result.returncode == 0, result.stderr assert "wheel validation passed" in result.stdout @@ -138,11 +152,12 @@ def _run_check_script( cargo_metadata_path: Path | None = None, expected_wheel_tag: str | None = None, cwd: Path = REPO_ROOT, + expected_version: str = EXPECTED_VERSION, ) -> subprocess.CompletedProcess[str]: env = os.environ.copy() env["AGENTIC_API_CHECK_PYTHON"] = sys.executable env["AGENTIC_API_CHECK_SCRIPTS_DIR"] = str(scripts_dir) - env["AGENTIC_API_EXPECTED_VERSION"] = EXPECTED_VERSION + env["AGENTIC_API_EXPECTED_VERSION"] = expected_version env["PYTHONPATH"] = str(site_packages) if cargo_metadata_path is not None: env["AGENTIC_API_CHECK_CARGO_METADATA_JSON"] = str(cargo_metadata_path) @@ -159,24 +174,31 @@ def _run_check_script( ) -def _write_fake_wheel(path: Path, extra_entries: dict[str, str] | None = None) -> Path: +def _write_fake_wheel( + path: Path, + extra_entries: dict[str, str] | None = None, + *, + version: str = EXPECTED_VERSION, +) -> Path: + dist_info = f"agentic_api-{version}.dist-info" + data_dir = f"agentic_api-{version}.data" entries = { - "agentic_api/__init__.py": "__version__ = '0.4.0'\n", - "agentic_api-0.4.0.dist-info/METADATA": textwrap.dedent( - """\ + "agentic_api/__init__.py": f"__version__ = '{version}'\n", + f"{dist_info}/METADATA": textwrap.dedent( + f"""\ Metadata-Version: 2.3 Name: agentic-api - Version: 0.4.0 + Version: {version} """ ), - "agentic_api-0.4.0.dist-info/entry_points.txt": textwrap.dedent( + f"{dist_info}/entry_points.txt": textwrap.dedent( """\ [console_scripts] agentic-api = agentic_api.cli:main """ ), - "agentic_api-0.4.0.data/scripts/agentic": "", - "agentic_api-0.4.0.data/scripts/agentic-server": "", + f"{data_dir}/scripts/agentic": "", + f"{data_dir}/scripts/agentic-server": "", } if extra_entries is not None: entries.update(extra_entries) @@ -187,19 +209,19 @@ def _write_fake_wheel(path: Path, extra_entries: dict[str, str] | None = None) - return path -def _write_fake_site_packages(path: Path) -> Path: +def _write_fake_site_packages(path: Path, *, version: str = EXPECTED_VERSION) -> Path: package_dir = path / "agentic_api" package_dir.mkdir(parents=True) - (package_dir / "__init__.py").write_text("__version__ = '0.4.0'\n", encoding="utf-8") + (package_dir / "__init__.py").write_text(f"__version__ = '{version}'\n", encoding="utf-8") - dist_info = path / "agentic_api-0.4.0.dist-info" + dist_info = path / f"agentic_api-{version}.dist-info" dist_info.mkdir() (dist_info / "METADATA").write_text( textwrap.dedent( - """\ + f"""\ Metadata-Version: 2.3 Name: agentic-api - Version: 0.4.0 + Version: {version} """ ), encoding="utf-8", @@ -230,41 +252,41 @@ def _write_fake_cargo_metadata( return path -def _write_fake_scripts(path: Path) -> Path: +def _write_fake_scripts(path: Path, *, version: str = EXPECTED_VERSION) -> Path: path.mkdir(parents=True) _write_executable( path / "agentic", - """ + f""" #!/usr/bin/env python3 import sys if sys.argv[1:] == ["--version"]: - print("agentic 0.4.0") + print("agentic {version}") raise SystemExit(0) raise SystemExit(1) """, ) _write_executable( path / "agentic-server", - """ + f""" #!/usr/bin/env python3 import sys if sys.argv[1:] == ["--version"]: - print("agentic-server 0.4.0") + print("agentic-server {version}") raise SystemExit(0) raise SystemExit(1) """, ) _write_executable( path / "agentic-api", - """ + f""" #!/usr/bin/env python3 import sys if sys.argv[1:] == ["version"]: - print("agentic-api version: 0.4.0") - print("Rust binary version: agentic-server 0.4.0") + print("agentic-api version: {version}") + print("Rust binary version: agentic-server {version}") print("Supported vLLM version: 0.11.0") print("Installed vLLM version: not installed") raise SystemExit(0) From dc942a39f175c518a6d0f267d16d95ad80fcb4da Mon Sep 17 00:00:00 2001 From: Francisco Javier Arceo Date: Wed, 26 Aug 2026 07:31:15 -0400 Subject: [PATCH 06/18] test: derive installed CLI version expectation Signed-off-by: Francisco Javier Arceo --- tests/python/test_installed_cli.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/python/test_installed_cli.py b/tests/python/test_installed_cli.py index 99775cf8..603f87b1 100644 --- a/tests/python/test_installed_cli.py +++ b/tests/python/test_installed_cli.py @@ -10,6 +10,8 @@ import pytest +from agentic_api import __version__ + COMMAND_TIMEOUT_S = 20 @@ -168,4 +170,4 @@ def test_agentic_api_entry_point_works_in_prefix_without_sibling_python(tmp_path ) assert result.returncode == 0, result.stderr - assert "agentic-api version: 0.4.0" in result.stdout + assert f"agentic-api version: {__version__}" in result.stdout From 7c734e2982c84c4526611d85e31406eeee00fe7d Mon Sep 17 00:00:00 2001 From: Francisco Javier Arceo Date: Wed, 26 Aug 2026 07:36:55 -0400 Subject: [PATCH 07/18] fix: improve Python launcher failure UX Signed-off-by: Francisco Javier Arceo --- README.md | 4 ++-- docs/guides/python-installation.md | 6 +++--- docs/index.md | 4 ++-- python/agentic_api/launcher.py | 2 +- tests/python/test_docs_examples.py | 5 ++++- tests/python/test_launcher.py | 26 ++++++++++++++++++++++++++ 6 files changed, 38 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 4eb33a40..670158f8 100644 --- a/README.md +++ b/README.md @@ -141,9 +141,9 @@ the launcher can manage a local vLLM process on supported Linux hosts. Use `agentic-api --version` for a quick install check and `agentic-api doctor --mode remote --json` when an agent or script needs machine-readable diagnostics. -#### Planned for 0.5.0 +#### After PyPI publication -These public-index and `uvx` examples apply only after the PyPI publication gate for 0.5.0 passes: +These public-index and `uvx` examples apply only after the PyPI publication gate passes: ```bash uv pip install agentic-api diff --git a/docs/guides/python-installation.md b/docs/guides/python-installation.md index 53fc486e..06e55ab0 100644 --- a/docs/guides/python-installation.md +++ b/docs/guides/python-installation.md @@ -47,10 +47,10 @@ agentic-api serve --model Qwen/Qwen3-30B-A3B-FP8 -- \ --max-model-len=32768 ``` -## Planned for 0.5.0 +## After PyPI publication -The following public-index installation and `uvx` commands are planned for 0.5.0. Use them only after the PyPI -publication gate has passed; they do not work until the package is published: +The following public-index installation and `uvx` commands work after the PyPI publication gate has passed. They do +not work until the package is published: ```bash uv pip install agentic-api diff --git a/docs/index.md b/docs/index.md index a626bc3c..073f7bef 100644 --- a/docs/index.md +++ b/docs/index.md @@ -38,8 +38,8 @@ Our first milestone is implementing the [Responses API](https://platform.openai. The `agentic-api` wheel packages the Rust gateway and a small Python launcher. Use the base package for proxy-only installations, and the `[local]` extra when you want the launcher to manage a local vLLM process. -- The 0.4.0 build-only release is installed from downloaded wheel artifacts and is not published on PyPI -- [Python installation and workflows](guides/python-installation.md) for current artifact installs, the future 0.5.0 public-index gate, `doctor`, and known-good model profiles +- The Python distribution is currently installed from downloaded wheel artifacts and is not published on PyPI +- [Python installation and workflows](guides/python-installation.md) for current artifact installs, the PyPI publication gate, `doctor`, and known-good model profiles - The Rust-native `agentic` CLI remains supported for `serve`, `run codex`, `run claude`, and `validate` - vLLM is a supported backend, not part of the Agentic API product name diff --git a/python/agentic_api/launcher.py b/python/agentic_api/launcher.py index 072ef1e0..0a7518ee 100644 --- a/python/agentic_api/launcher.py +++ b/python/agentic_api/launcher.py @@ -53,7 +53,7 @@ def begin_shutdown(signum: int, _frame: object) -> None: signal_exit_code = _signal_exit_code(signal.SIGINT) supervisor.request_shutdown(signal.SIGINT) return signal_exit_code - except (FileNotFoundError, PackagedBinaryNotFoundError, RuntimeError, ValueError) as error: + except (OSError, PackagedBinaryNotFoundError, RuntimeError, ValueError) as error: if signal_exit_code is not None: return signal_exit_code print(str(error), file=sys.stderr) diff --git a/tests/python/test_docs_examples.py b/tests/python/test_docs_examples.py index fde3c0d5..7640afb3 100644 --- a/tests/python/test_docs_examples.py +++ b/tests/python/test_docs_examples.py @@ -19,8 +19,11 @@ def test_documented_python_install_commands_respect_release_publication_gate() - assert 'uv pip install "$WHEEL_PATH"' in combined assert 'agentic-api[local] @ file://$WHEEL_PATH' in combined assert "This release produces wheel artifacts" in combined - assert "Planned for 0.5.0" in combined + assert "After PyPI publication" in combined assert "after the PyPI publication gate" in combined + assert "Planned for 0.5.0" not in combined + assert "0.4.0 build-only release" not in combined + assert "future 0.5.0 public-index gate" not in combined assert "uv pip install agentic-api" in combined assert 'uv pip install "agentic-api[local]"' in combined assert "uvx --from agentic-api agentic-api doctor" in combined diff --git a/tests/python/test_launcher.py b/tests/python/test_launcher.py index 973d8a1e..9673d32b 100644 --- a/tests/python/test_launcher.py +++ b/tests/python/test_launcher.py @@ -234,6 +234,32 @@ def test_run_serve_reports_startup_failure_and_cleans_up_started_children( assert supervisor.terminate_timeout == 10.0 +def test_run_serve_reports_readiness_timeout_without_traceback( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + import agentic_api.launcher as launcher + + supervisor = FakeSupervisor() + + monkeypatch.setattr(launcher, "ProcessSupervisor", lambda: supervisor) + monkeypatch.setattr(launcher, "find_active_environment_executable", lambda name: Path("/venv/bin/vllm")) + monkeypatch.setattr(launcher, "_installed_vllm_version", lambda: "0.11.0") + monkeypatch.setattr(launcher.secrets, "token_urlsafe", lambda _: "generated-token") + monkeypatch.setattr( + launcher, + "wait_for_vllm_ready", + lambda *args, **kwargs: (_ for _ in ()).throw(TimeoutError("timed out waiting for vLLM readiness")), + ) + monkeypatch.setattr(launcher.signal, "signal", lambda sig, handler: handler) + + exit_code = launcher.run_serve(make_options()) + + assert exit_code == 1 + assert capsys.readouterr().err == "timed out waiting for vLLM readiness\n" + assert len(supervisor.starts) == 1 + assert supervisor.terminate_timeout == 10.0 + + def test_run_serve_restores_prior_signal_handlers(monkeypatch: pytest.MonkeyPatch) -> None: import agentic_api.launcher as launcher From 12cfa1d1acf1aeb091c0c3b6e5af588d4ee369c6 Mon Sep 17 00:00:00 2001 From: Francisco Javier Arceo Date: Wed, 26 Aug 2026 07:38:35 -0400 Subject: [PATCH 08/18] fix: bound packaged binary version probes Signed-off-by: Francisco Javier Arceo --- python/agentic_api/binary.py | 4 ++++ tests/python/test_binary.py | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/python/agentic_api/binary.py b/python/agentic_api/binary.py index 6e7d4b1a..d0fa20b9 100644 --- a/python/agentic_api/binary.py +++ b/python/agentic_api/binary.py @@ -9,6 +9,7 @@ REMEDIATION_MESSAGE = "Reinstall agentic-api for this platform" +BINARY_VERSION_TIMEOUT_S = 5.0 class PackagedBinaryNotFoundError(FileNotFoundError): @@ -41,9 +42,12 @@ def read_binary_version(path: Path) -> str: check=True, capture_output=True, text=True, + timeout=BINARY_VERSION_TIMEOUT_S, ) except OSError as error: # pragma: no cover - exercised via unit tests. raise PackagedBinaryVersionError(f"unable to launch {path}: {error.strerror or error}") from error + except subprocess.TimeoutExpired as error: + raise PackagedBinaryVersionError(f"{path} timed out while reporting its version") from error except subprocess.CalledProcessError as error: raise PackagedBinaryVersionError( f"{path} exited with status {error.returncode} while reporting its version" diff --git a/tests/python/test_binary.py b/tests/python/test_binary.py index 320d090d..4cb5f132 100644 --- a/tests/python/test_binary.py +++ b/tests/python/test_binary.py @@ -57,3 +57,15 @@ def test_read_binary_version_returns_first_line_from_version_output(tmp_path: Pa binary.chmod(0o755) assert read_binary_version(binary) == "agentic-server 0.4.0" + + +def test_read_binary_version_reports_a_hung_binary_without_waiting_forever( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + binary = tmp_path / "agentic-server" + binary.write_text("#!/bin/sh\nsleep 1\n") + binary.chmod(0o755) + monkeypatch.setattr("agentic_api.binary.BINARY_VERSION_TIMEOUT_S", 0.01) + + with pytest.raises(RuntimeError, match="timed out while reporting its version"): + read_binary_version(binary) From c85933735bd84fed9e4598da096ada2200572388 Mon Sep 17 00:00:00 2001 From: Francisco Javier Arceo Date: Wed, 26 Aug 2026 07:43:28 -0400 Subject: [PATCH 09/18] fix: clarify missing Python launcher errors Signed-off-by: Francisco Javier Arceo --- python/agentic_api/cli.py | 10 ++++++++-- tests/python/test_cli.py | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/python/agentic_api/cli.py b/python/agentic_api/cli.py index 9fe9223d..42674865 100644 --- a/python/agentic_api/cli.py +++ b/python/agentic_api/cli.py @@ -100,8 +100,14 @@ def main(argv: Sequence[str] | None = None) -> int: if namespace.command == "serve": try: from agentic_api.launcher import run_serve - except ModuleNotFoundError: - print("agentic-api serve is not implemented in this build yet.", file=sys.stderr) + except ModuleNotFoundError as error: + if error.name != "agentic_api.launcher": + raise + print( + "agentic-api serve is unavailable because the Python launcher is missing; " + "reinstall agentic-api for this platform.", + file=sys.stderr, + ) return 1 return run_serve(namespace.options) diff --git a/tests/python/test_cli.py b/tests/python/test_cli.py index e9d7a002..c5270a61 100644 --- a/tests/python/test_cli.py +++ b/tests/python/test_cli.py @@ -1,5 +1,6 @@ from __future__ import annotations +import builtins from dataclasses import asdict import pytest @@ -230,3 +231,24 @@ def test_doctor_json_flag_is_forwarded_to_diagnostics(monkeypatch: pytest.Monkey assert main(["doctor", "--mode", "remote", "--json"]) == 0 assert calls == [("remote", True)] + + +def test_serve_reports_a_missing_packaged_launcher_with_remediation( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + real_import = builtins.__import__ + + def missing_launcher(name: str, *args: object, **kwargs: object) -> object: + if name == "agentic_api.launcher": + raise ModuleNotFoundError("agentic_api.launcher", name="agentic_api.launcher") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", missing_launcher) + + exit_code = main(["serve", "--vllm-base-url", "http://existing-vllm:8000"]) + + assert exit_code == 1 + assert capsys.readouterr().err == ( + "agentic-api serve is unavailable because the Python launcher is missing; " + "reinstall agentic-api for this platform.\n" + ) From 14a551d0c0d3ae80ebe37b27d6c4c37ba83a081d Mon Sep 17 00:00:00 2001 From: Francisco Javier Arceo Date: Wed, 26 Aug 2026 07:48:03 -0400 Subject: [PATCH 10/18] fix: clarify unsupported local platforms Signed-off-by: Francisco Javier Arceo --- python/agentic_api/diagnostics.py | 20 ++++++++++++++++++-- tests/python/test_diagnostics.py | 26 ++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/python/agentic_api/diagnostics.py b/python/agentic_api/diagnostics.py index 634c2f63..ea7040a4 100644 --- a/python/agentic_api/diagnostics.py +++ b/python/agentic_api/diagnostics.py @@ -54,7 +54,11 @@ def collect_doctor_report() -> DoctorReport: vllm_executable_path = str(find_active_environment_executable("vllm")) except FileNotFoundError: vllm_executable_path = "not found" - local_ok, local_message = _local_health(installed_vllm_version, vllm_executable_path) + local_ok, local_message = _local_health( + installed_vllm_version, + vllm_executable_path, + platform_name=platform.system(), + ) return DoctorReport( python_version=platform.python_version(), @@ -122,7 +126,19 @@ def _rust_binary_details() -> tuple[str, bool, str]: return (str(path), executable, version) -def _local_health(installed_vllm_version: str, vllm_executable_path: str) -> tuple[bool, str]: +def _local_health( + installed_vllm_version: str, + vllm_executable_path: str, + *, + platform_name: str, +) -> tuple[bool, str]: + if platform_name != "Linux" and installed_vllm_version == "not installed": + return ( + False, + "Local mode is currently supported only on Linux because the [local] extra installs vLLM only on Linux. " + "Use remote mode on this platform, or run local mode on Linux.", + ) + install_hint = ( "Install the agentic-api wheel artifact with its local extra: " '`uv pip install "agentic-api[local] @ file:///path/to/agentic_api-PLATFORM.whl"`.' diff --git a/tests/python/test_diagnostics.py b/tests/python/test_diagnostics.py index b7b7c9cc..72f198fa 100644 --- a/tests/python/test_diagnostics.py +++ b/tests/python/test_diagnostics.py @@ -49,6 +49,7 @@ def test_local_doctor_reports_missing_vllm_installation( "agentic_api.diagnostics.find_active_environment_executable", lambda name: (_ for _ in ()).throw(FileNotFoundError(name)), ) + monkeypatch.setattr("agentic_api.diagnostics.platform.system", lambda: "Linux") exit_code = diagnostics.doctor("local") output = capsys.readouterr().out @@ -60,6 +61,31 @@ def test_local_doctor_reports_missing_vllm_installation( assert "file:///path/to/agentic_api-PLATFORM.whl" in output +def test_local_doctor_explains_linux_only_extra_on_unsupported_platform( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path +) -> None: + rust_binary = tmp_path / "agentic-server" + rust_binary.write_text("") + rust_binary.chmod(0o755) + + monkeypatch.setattr("agentic_api.diagnostics.find_packaged_binary", lambda name: rust_binary) + monkeypatch.setattr("agentic_api.diagnostics.read_binary_version", lambda path: "agentic-server 0.5.0") + monkeypatch.setattr("agentic_api.diagnostics.metadata_version", _metadata_version_without_vllm) + monkeypatch.setattr( + "agentic_api.diagnostics.find_active_environment_executable", + lambda name: (_ for _ in ()).throw(FileNotFoundError(name)), + ) + monkeypatch.setattr("agentic_api.diagnostics.platform.system", lambda: "Darwin") + + exit_code = diagnostics.doctor("local") + output = capsys.readouterr().out + + assert exit_code == 1 + assert "Local mode health: unavailable" in output + assert "Local mode is currently supported only on Linux" in output + assert "remote mode" in output + + def test_local_doctor_reports_incompatible_vllm_version( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path ) -> None: From 784432253097440d5c0911a84526ca6206068e9f Mon Sep 17 00:00:00 2001 From: Francisco Javier Arceo Date: Wed, 26 Aug 2026 07:56:11 -0400 Subject: [PATCH 11/18] fix: reject blank local models Signed-off-by: Francisco Javier Arceo --- python/agentic_api/cli.py | 3 +++ tests/python/test_cli.py | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/python/agentic_api/cli.py b/python/agentic_api/cli.py index 42674865..1a693056 100644 --- a/python/agentic_api/cli.py +++ b/python/agentic_api/cli.py @@ -117,6 +117,9 @@ def main(argv: Sequence[str] | None = None) -> int: def _build_serve_options(parser: argparse.ArgumentParser, namespace: argparse.Namespace) -> ServeOptions: + if namespace.model is not None and not namespace.model.strip(): + parser.error("--model must not be empty") + source_count = int(bool(namespace.model)) + int(bool(namespace.vllm_base_url)) if source_count != 1: parser.error("exactly one of --model or --vllm-base-url is required") diff --git a/tests/python/test_cli.py b/tests/python/test_cli.py index c5270a61..3c05c958 100644 --- a/tests/python/test_cli.py +++ b/tests/python/test_cli.py @@ -78,6 +78,15 @@ def test_serve_requires_exactly_one_source_option(args: list[str], message: str, assert message in capsys.readouterr().err +@pytest.mark.parametrize("model", ["", " "]) +def test_serve_rejects_empty_model(capsys: pytest.CaptureFixture[str], model: str) -> None: + with pytest.raises(SystemExit) as exc_info: + build_parser().parse_args(["serve", "--model", model]) + + assert exc_info.value.code == 2 + assert "--model must not be empty" in capsys.readouterr().err + + def test_serve_preserves_passthrough_after_double_dash() -> None: options = parse_serve_args( "--model", From 30f7b59f45213dbc68bf60b18ad34896b4dcdc7e Mon Sep 17 00:00:00 2001 From: Francisco Javier Arceo Date: Wed, 26 Aug 2026 13:28:23 -0400 Subject: [PATCH 12/18] fix: address Python package review findings Signed-off-by: Francisco Javier Arceo --- AGENTS.md | 12 +++++++- docs/guides/python-installation.md | 3 ++ python/agentic_api/binary.py | 16 +++++----- python/agentic_api/cli.py | 21 ++++++++++--- python/agentic_api/diagnostics.py | 2 +- python/agentic_api/launcher.py | 13 ++++++-- python/agentic_api/process.py | 2 +- scripts/check-python-wheel.sh | 16 ++++++++-- tests/python/test_binary.py | 18 +++++++++++ tests/python/test_cli.py | 13 ++++++++ tests/python/test_diagnostics.py | 23 ++++++++++++++ tests/python/test_installed_cli.py | 3 +- tests/python/test_launcher.py | 26 +++++++++++++--- tests/python/test_process.py | 17 +++++++++++ tests/python/test_wheel_check.py | 48 +++++++++++++++++++++++++++--- 15 files changed, 204 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9504be54..4a2a9b7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,8 @@ This repository is Rust-first under the `vllm-project` GitHub organization. - **Rust** -- primary and active implementation language at the repo root. - **Docs** -- MkDocs documentation in `docs/`. -- **Python gateway code has been removed** as part of the migration plan. +- **Python** -- a lightweight distribution and launcher for the packaged Rust gateway; the gateway implementation + remains Rust-first. ## Terminology @@ -27,6 +28,9 @@ This repository is Rust-first under the `vllm-project` GitHub organization. ├── crates/agentic-server/ # Axum binary, transport handlers, and configuration ├── crates/agentic-server-core/ # Protocol types, execution, tools, and persistence ├── crates/agentic-praxis/ # Praxis integration +├── python/agentic_api/ # Python distribution, diagnostics, and launcher +├── tests/python/ # Python package and CLI tests +├── pyproject.toml # Python wheel build metadata ├── Cargo.toml # Workspace manifest and shared dependencies/lints └── docs/ # Documentation (MkDocs) ``` @@ -44,6 +48,12 @@ cargo build ```bash cargo test + +# Python distribution and CLI tests +.venv/bin/python -m pytest tests/python + +# Source-install CLI E2E (also exercises the packaged wheel build) +python3 scripts/tests/agentic-cli-e2e-test.py ``` - Before adding or updating replay cassettes, read `crates/agentic-server-core/tests/cassettes/README.md` and use its diff --git a/docs/guides/python-installation.md b/docs/guides/python-installation.md index 06e55ab0..69133b42 100644 --- a/docs/guides/python-installation.md +++ b/docs/guides/python-installation.md @@ -64,6 +64,9 @@ uvx --from agentic-api agentic-api serve --vllm-base-url http://existing-vllm:80 `doctor` reports whether the packaged Rust executable is present, whether the tested local vLLM wheel is installed, and whether the current mode is healthy. +With no mode selected, `doctor` reports both local and remote health but uses remote health for its exit status, so the +base proxy-only install is considered healthy when its packaged gateway is available. + ```bash agentic-api doctor agentic-api doctor --mode remote diff --git a/python/agentic_api/binary.py b/python/agentic_api/binary.py index d0fa20b9..117f9d0e 100644 --- a/python/agentic_api/binary.py +++ b/python/agentic_api/binary.py @@ -21,10 +21,9 @@ class PackagedBinaryVersionError(RuntimeError): def find_packaged_binary(name: str) -> Path: - try: - return find_active_environment_executable(name) - except FileNotFoundError: - pass + for candidate in _candidate_paths(name, include_ambient_path=False): + if _is_executable_file(candidate): + return candidate raise PackagedBinaryNotFoundError(f"{name} not found; {REMEDIATION_MESSAGE}") @@ -59,7 +58,7 @@ def read_binary_version(path: Path) -> str: return output.splitlines()[0].strip() -def _candidate_paths(name: str) -> list[Path]: +def _candidate_paths(name: str, *, include_ambient_path: bool = True) -> list[Path]: candidates: list[Path] = [] scripts_dir = sysconfig.get_path("scripts") @@ -68,9 +67,10 @@ def _candidate_paths(name: str) -> list[Path]: candidates.append(Path(sys.executable).resolve().parent / name) - which_path = shutil.which(name) - if which_path: - candidates.append(Path(which_path)) + if include_ambient_path: + which_path = shutil.which(name) + if which_path: + candidates.append(Path(which_path)) unique_candidates: list[Path] = [] seen: set[Path] = set() diff --git a/python/agentic_api/cli.py b/python/agentic_api/cli.py index 1a693056..86a497b6 100644 --- a/python/agentic_api/cli.py +++ b/python/agentic_api/cli.py @@ -52,11 +52,17 @@ def parse_args(self, args: Sequence[str] | None = None, namespace: argparse.Name def build_parser() -> argparse.ArgumentParser: - parser = _AgenticArgumentParser(prog="agentic-api", description="Python launcher for packaged Agentic API binaries") + parser = _AgenticArgumentParser( + prog="agentic-api", + description="Python launcher for packaged Agentic API binaries", + allow_abbrev=False, + ) parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") subparsers = parser.add_subparsers(dest="command", required=True) - serve_parser = subparsers.add_parser("serve", help="Launch Agentic API in local or remote mode") + serve_parser = subparsers.add_parser( + "serve", help="Launch Agentic API in local or remote mode", allow_abbrev=False + ) serve_parser.add_argument("--model") serve_parser.add_argument("--vllm-base-url") serve_parser.add_argument("--host", default=DEFAULT_HOST) @@ -68,7 +74,9 @@ def build_parser() -> argparse.ArgumentParser: serve_parser.add_argument("--vllm-api-key-env", default=DEFAULT_VLLM_API_KEY_ENV) serve_parser.add_argument("vllm_args", nargs=argparse.REMAINDER) - doctor_parser = subparsers.add_parser("doctor", help="Report packaged binary and compatibility diagnostics") + doctor_parser = subparsers.add_parser( + "doctor", help="Report packaged binary and compatibility diagnostics", allow_abbrev=False + ) doctor_parser.add_argument("--mode", choices=("local", "remote")) doctor_parser.add_argument( "--json", action="store_true", dest="json_output", help="Emit a machine-readable JSON report" @@ -135,6 +143,9 @@ def _build_serve_options(parser: argparse.ArgumentParser, namespace: argparse.Na vllm_base_url = _normalize_base_url(parser, namespace.vllm_base_url) mode = "remote" + if mode == "remote" and vllm_args: + parser.error("vLLM passthrough arguments are only supported with --model (local mode)") + return ServeOptions( mode=mode, model=namespace.model, @@ -161,7 +172,9 @@ def _reserved_vllm_flag(values: Sequence[str]) -> str | None: if not value.startswith("--"): continue option_name = value.partition("=")[0].replace("_", "-") - if option_name in RESERVED_VLLM_FLAGS or option_name in INCOMPATIBLE_VLLM_FLAGS: + if len(option_name) > 2 and any( + reserved.startswith(option_name) for reserved in RESERVED_VLLM_FLAGS | INCOMPATIBLE_VLLM_FLAGS + ): return option_name return None diff --git a/python/agentic_api/diagnostics.py b/python/agentic_api/diagnostics.py index ea7040a4..b4a2eb80 100644 --- a/python/agentic_api/diagnostics.py +++ b/python/agentic_api/diagnostics.py @@ -43,7 +43,7 @@ def doctor(mode: str | None, *, json_output: bool = False) -> int: return 0 if report.local_ok else 1 if mode == "remote": return 0 if report.remote_ok else 1 - return 0 if report.local_ok and report.remote_ok else 1 + return 0 if report.remote_ok else 1 def collect_doctor_report() -> DoctorReport: diff --git a/python/agentic_api/launcher.py b/python/agentic_api/launcher.py index 0a7518ee..e496e902 100644 --- a/python/agentic_api/launcher.py +++ b/python/agentic_api/launcher.py @@ -88,10 +88,8 @@ def _run_local_mode(supervisor: ProcessSupervisor, options: ServeOptions) -> Chi "127.0.0.1", "--port", str(options.vllm_port), - "--api-key", - vllm_api_key, ], - os.environ.copy(), + _vllm_environment(vllm_api_key), ) wait_for_vllm_ready( base_url=vllm_url, @@ -143,6 +141,12 @@ def _rust_environment(gateway_api_key_env: str, api_key_override: str | None) -> return env +def _vllm_environment(api_key: str) -> dict[str, str]: + env = os.environ.copy() + env["VLLM_API_KEY"] = api_key + return env + + def _installed_vllm_version() -> str: try: return version("vllm") @@ -153,6 +157,9 @@ def _installed_vllm_version() -> str: def _normalize_exit_code(result: ChildResult) -> int: + if result.returncode == 0: + print(f"{result.name} exited unexpectedly with status 0", file=sys.stderr) + return 1 if result.returncode >= 0: return result.returncode return _signal_exit_code(-result.returncode) diff --git a/python/agentic_api/process.py b/python/agentic_api/process.py index 3064a81f..cff8716a 100644 --- a/python/agentic_api/process.py +++ b/python/agentic_api/process.py @@ -14,7 +14,7 @@ POLL_INTERVAL_S = 0.05 -READY_REQUEST_TIMEOUT_S = 0.1 +READY_REQUEST_TIMEOUT_S = 5.0 _IS_POSIX = os.name == "posix" diff --git a/scripts/check-python-wheel.sh b/scripts/check-python-wheel.sh index 9ed805df..48243145 100755 --- a/scripts/check-python-wheel.sh +++ b/scripts/check-python-wheel.sh @@ -8,13 +8,25 @@ fi wheel_path="$1" check_python="${AGENTIC_API_CHECK_PYTHON:-python}" -expected_version="${AGENTIC_API_EXPECTED_VERSION:-0.4.0}" expected_wheel_tag="${AGENTIC_API_EXPECTED_WHEEL_TAG:-}" scripts_dir="${AGENTIC_API_CHECK_SCRIPTS_DIR:-}" script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" repo_root="$(cd -- "$script_dir/.." && pwd -P)" cargo_manifest_path="$repo_root/Cargo.toml" +if [ -n "${AGENTIC_API_EXPECTED_VERSION:-}" ]; then + expected_version="$AGENTIC_API_EXPECTED_VERSION" +else + cargo_metadata="$(cargo metadata --format-version 1 --no-deps --manifest-path "$cargo_manifest_path")" + expected_version="$(printf '%s\n' "$cargo_metadata" | "$check_python" -c ' +import json +import sys + +packages = json.load(sys.stdin)["packages"] +print(next(package["version"] for package in packages if package["name"] == "agentic-server")) +')" +fi + if [ ! -f "$wheel_path" ]; then echo "wheel file not found: $wheel_path" >&2 exit 1 @@ -189,7 +201,7 @@ with zipfile.ZipFile(wheel_path) as archive: def has_packaged_script(script_name: str) -> bool: return any( - f"/scripts/{script_name}" in f"/{name}" and ".data/" in name + name.endswith(f".data/scripts/{script_name}") for name in normalized_names ) diff --git a/tests/python/test_binary.py b/tests/python/test_binary.py index 4cb5f132..86abc6f2 100644 --- a/tests/python/test_binary.py +++ b/tests/python/test_binary.py @@ -51,6 +51,24 @@ def test_find_packaged_binary_reports_remediation_when_packaged_binary_is_missin find_packaged_binary("agentic-server") +def test_find_packaged_binary_does_not_use_an_ambient_path_binary( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + scripts_dir = tmp_path / "env" / "bin" + scripts_dir.mkdir(parents=True) + ambient_binary = tmp_path / "unrelated" / "agentic-server" + ambient_binary.parent.mkdir(parents=True) + ambient_binary.write_text("#!/bin/sh\nexit 0\n") + ambient_binary.chmod(0o755) + + monkeypatch.setattr("agentic_api.binary.sysconfig.get_path", lambda name: str(scripts_dir)) + monkeypatch.setattr("agentic_api.binary.sys.executable", str(tmp_path / "env" / "bin" / "python")) + monkeypatch.setattr("agentic_api.binary.shutil.which", lambda name: str(ambient_binary)) + + with pytest.raises(PackagedBinaryNotFoundError): + find_packaged_binary("agentic-server") + + def test_read_binary_version_returns_first_line_from_version_output(tmp_path: Path) -> None: binary = tmp_path / "agentic-server" binary.write_text("#!/bin/sh\nprintf 'agentic-server 0.4.0\\nextra detail\\n'\n") diff --git a/tests/python/test_cli.py b/tests/python/test_cli.py index 3c05c958..e1e355a5 100644 --- a/tests/python/test_cli.py +++ b/tests/python/test_cli.py @@ -100,6 +100,16 @@ def test_serve_preserves_passthrough_after_double_dash() -> None: assert options.vllm_args == ["--dtype", "bfloat16", "--max-model-len=32768"] +def test_remote_serve_rejects_vllm_passthrough_arguments(capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as exc_info: + build_parser().parse_args( + ["serve", "--vllm-base-url", "http://existing-vllm:8000", "--", "--dtype", "bfloat16"] + ) + + assert exc_info.value.code == 2 + assert "only supported with --model" in capsys.readouterr().err + + @pytest.mark.parametrize( "args", [ @@ -113,6 +123,9 @@ def test_serve_preserves_passthrough_after_double_dash() -> None: ["--model", "Qwen/Qwen3-4B", "--", "--api_key=secret"], ["--model", "Qwen/Qwen3-4B", "--", "--uds", "/tmp/vllm.sock"], ["--model", "Qwen/Qwen3-4B", "--", "--uds=/tmp/vllm.sock"], + ["--model", "Qwen/Qwen3-4B", "--", "--por", "9999"], + ["--model", "Qwen/Qwen3-4B", "--", "--api-ke", "secret"], + ["--model", "Qwen/Qwen3-4B", "--", "--ud", "/tmp/vllm.sock"], ], ) def test_serve_rejects_launcher_owned_vllm_passthrough_flags( diff --git a/tests/python/test_diagnostics.py b/tests/python/test_diagnostics.py index 72f198fa..8d18e7bd 100644 --- a/tests/python/test_diagnostics.py +++ b/tests/python/test_diagnostics.py @@ -35,6 +35,29 @@ def test_remote_doctor_is_healthy_without_vllm( assert "Remote mode health: ok" in output +def test_doctor_without_mode_succeeds_for_a_healthy_base_install( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path +) -> None: + rust_binary = tmp_path / "agentic-server" + rust_binary.write_text("") + rust_binary.chmod(0o755) + + monkeypatch.setattr("agentic_api.diagnostics.find_packaged_binary", lambda name: rust_binary) + monkeypatch.setattr("agentic_api.diagnostics.read_binary_version", lambda path: "agentic-server 0.4.0") + monkeypatch.setattr("agentic_api.diagnostics.metadata_version", _metadata_version_without_vllm) + monkeypatch.setattr( + "agentic_api.diagnostics.find_active_environment_executable", + lambda name: (_ for _ in ()).throw(FileNotFoundError(name)), + ) + + exit_code = diagnostics.doctor(None) + output = capsys.readouterr().out + + assert exit_code == 0 + assert "Selected mode: all" in output + assert "Remote mode health: ok" in output + + def test_local_doctor_reports_missing_vllm_installation( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path ) -> None: diff --git a/tests/python/test_installed_cli.py b/tests/python/test_installed_cli.py index 603f87b1..d60b2efe 100644 --- a/tests/python/test_installed_cli.py +++ b/tests/python/test_installed_cli.py @@ -110,7 +110,8 @@ def test_agentic_api_serve_uses_discovered_packaged_server_without_importing_vll env=env, ) - assert result.returncode == 0, result.stderr + assert result.returncode == 1, result.stderr + assert "agentic-server exited unexpectedly with status 0" in result.stderr record = json.loads(record_path.read_text(encoding="utf-8")) assert record == { "argv": [ diff --git a/tests/python/test_launcher.py b/tests/python/test_launcher.py index 9673d32b..52454d64 100644 --- a/tests/python/test_launcher.py +++ b/tests/python/test_launcher.py @@ -99,7 +99,7 @@ def test_run_serve_local_mode_starts_vllm_then_rust(monkeypatch: pytest.MonkeyPa supervisor = FakeSupervisor.instances[-1] - assert exit_code == 0 + assert exit_code == 1 assert supervisor.starts[0][0] == [ "/venv/bin/vllm", "serve", @@ -111,9 +111,9 @@ def test_run_serve_local_mode_starts_vllm_then_rust(monkeypatch: pytest.MonkeyPa "127.0.0.1", "--port", "8000", - "--api-key", - "generated-token", ] + assert supervisor.starts[0][1]["VLLM_API_KEY"] == "generated-token" + assert "generated-token" not in supervisor.starts[0][0] assert supervisor.starts[1][0] == [ "/pkg/bin/agentic-server", "--llm-api-base", @@ -234,6 +234,24 @@ def test_run_serve_reports_startup_failure_and_cleans_up_started_children( assert supervisor.terminate_timeout == 10.0 +def test_run_serve_reports_clean_child_exit_as_unexpected_failure( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + import agentic_api.launcher as launcher + + supervisor = FakeSupervisor() + monkeypatch.setattr(launcher, "ProcessSupervisor", lambda: supervisor) + monkeypatch.setattr(launcher, "find_packaged_binary", lambda name: Path("/pkg/bin/agentic-server")) + monkeypatch.setattr(launcher.signal, "signal", lambda sig, handler: handler) + + exit_code = launcher.run_serve( + make_options(mode="remote", model=None, vllm_base_url="https://upstream.example.com/base") + ) + + assert exit_code == 1 + assert "agentic-server exited unexpectedly with status 0" in capsys.readouterr().err + + def test_run_serve_reports_readiness_timeout_without_traceback( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: @@ -284,7 +302,7 @@ def fake_signal(sig: int, handler: Any) -> Any: make_options(mode="remote", model=None, vllm_base_url="https://upstream.example.com/base") ) - assert exit_code == 0 + assert exit_code == 1 assert registered_handlers[signal.SIGINT] is previous_sigint assert registered_handlers[signal.SIGTERM] is previous_sigterm diff --git a/tests/python/test_process.py b/tests/python/test_process.py index 5fed8731..acf3aea0 100644 --- a/tests/python/test_process.py +++ b/tests/python/test_process.py @@ -251,6 +251,23 @@ def test_wait_for_failure_returns_exited_child_status(monkeypatch: pytest.Monkey ) +def test_wait_for_vllm_ready_allows_a_slow_successful_probe( + models_server: tuple[ThreadingHTTPServer, str], +) -> None: + server, base_url = models_server + ModelsHandler.response_delay_s = 0.2 + + wait_for_vllm_ready( + base_url=base_url, + api_key=None, + process=DummyProcess([None]), + timeout=0.5, + interval=0.01, + ) + + assert server is not None + + def test_wait_for_failure_raises_shutdown_requested(monkeypatch: pytest.MonkeyPatch) -> None: fake_process = FakePopen(pid=77, poll_values=[None, None, None]) diff --git a/tests/python/test_wheel_check.py b/tests/python/test_wheel_check.py index 038dcf0c..274a33c2 100644 --- a/tests/python/test_wheel_check.py +++ b/tests/python/test_wheel_check.py @@ -33,6 +33,29 @@ def test_check_python_wheel_accepts_expected_wheel_and_installed_environment(tmp assert "wheel validation passed" in result.stdout +def test_check_python_wheel_derives_version_for_bare_invocation(tmp_path: Path) -> None: + wheel_path = _write_fake_wheel( + tmp_path / f"agentic_api-{WORKSPACE_VERSION}-py3-none-any.whl", + version=WORKSPACE_VERSION, + ) + cargo_metadata_path = _write_fake_cargo_metadata( + tmp_path / "cargo-metadata.json", + package_versions={name: WORKSPACE_VERSION for name in ("agentic-praxis", "agentic-server-core", "agentic-server")}, + ) + site_packages = _write_fake_site_packages(tmp_path / "site-packages", version=WORKSPACE_VERSION) + scripts_dir = _write_fake_scripts(tmp_path / "bin", version=WORKSPACE_VERSION) + + result = _run_check_script( + wheel_path, + site_packages, + scripts_dir, + cargo_metadata_path=cargo_metadata_path, + expected_version=None, + ) + + assert result.returncode == 0, result.stderr + + def test_check_python_wheel_rejects_vllm_payloads(tmp_path: Path) -> None: wheel_path = _write_fake_wheel( tmp_path / "agentic_api-0.4.0-py3-none-any.whl", @@ -144,6 +167,22 @@ def test_check_python_wheel_validates_workspace_versions_outside_repository_cwd( assert "wheel validation passed" in result.stdout +def test_check_python_wheel_does_not_accept_a_similar_script_name(tmp_path: Path) -> None: + wheel_path = _write_fake_wheel( + tmp_path / "agentic_api-0.4.0-py3-none-any.whl", + packaged_scripts=("agentic-server",), + extra_entries={"agentic_api-0.4.0.data/scripts/agentic-api": ""}, + ) + cargo_metadata_path = _write_fake_cargo_metadata(tmp_path / "cargo-metadata.json") + site_packages = _write_fake_site_packages(tmp_path / "site-packages") + scripts_dir = _write_fake_scripts(tmp_path / "bin") + + result = _run_check_script(wheel_path, site_packages, scripts_dir, cargo_metadata_path=cargo_metadata_path) + + assert result.returncode != 0 + assert "wheel missing packaged executable: agentic" in result.stderr + + def _run_check_script( wheel_path: Path, site_packages: Path, @@ -152,12 +191,13 @@ def _run_check_script( cargo_metadata_path: Path | None = None, expected_wheel_tag: str | None = None, cwd: Path = REPO_ROOT, - expected_version: str = EXPECTED_VERSION, + expected_version: str | None = EXPECTED_VERSION, ) -> subprocess.CompletedProcess[str]: env = os.environ.copy() env["AGENTIC_API_CHECK_PYTHON"] = sys.executable env["AGENTIC_API_CHECK_SCRIPTS_DIR"] = str(scripts_dir) - env["AGENTIC_API_EXPECTED_VERSION"] = expected_version + if expected_version is not None: + env["AGENTIC_API_EXPECTED_VERSION"] = expected_version env["PYTHONPATH"] = str(site_packages) if cargo_metadata_path is not None: env["AGENTIC_API_CHECK_CARGO_METADATA_JSON"] = str(cargo_metadata_path) @@ -179,6 +219,7 @@ def _write_fake_wheel( extra_entries: dict[str, str] | None = None, *, version: str = EXPECTED_VERSION, + packaged_scripts: tuple[str, ...] = ("agentic", "agentic-server"), ) -> Path: dist_info = f"agentic_api-{version}.dist-info" data_dir = f"agentic_api-{version}.data" @@ -197,9 +238,8 @@ def _write_fake_wheel( agentic-api = agentic_api.cli:main """ ), - f"{data_dir}/scripts/agentic": "", - f"{data_dir}/scripts/agentic-server": "", } + entries.update({f"{data_dir}/scripts/{script_name}": "" for script_name in packaged_scripts}) if extra_entries is not None: entries.update(extra_entries) From 8f1dd09f4923f0a0b92de360f58a745aa1e7740a Mon Sep 17 00:00:00 2001 From: Francisco Javier Arceo Date: Wed, 26 Aug 2026 13:30:46 -0400 Subject: [PATCH 13/18] fix: find binaries beside prefixed console scripts Signed-off-by: Francisco Javier Arceo --- python/agentic_api/binary.py | 2 ++ tests/python/test_binary.py | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/python/agentic_api/binary.py b/python/agentic_api/binary.py index 117f9d0e..49560033 100644 --- a/python/agentic_api/binary.py +++ b/python/agentic_api/binary.py @@ -66,6 +66,8 @@ def _candidate_paths(name: str, *, include_ambient_path: bool = True) -> list[Pa candidates.append(Path(scripts_dir) / name) candidates.append(Path(sys.executable).resolve().parent / name) + if sys.argv and sys.argv[0]: + candidates.append(Path(sys.argv[0]).resolve().parent / name) if include_ambient_path: which_path = shutil.which(name) diff --git a/tests/python/test_binary.py b/tests/python/test_binary.py index 86abc6f2..d9186281 100644 --- a/tests/python/test_binary.py +++ b/tests/python/test_binary.py @@ -69,6 +69,26 @@ def test_find_packaged_binary_does_not_use_an_ambient_path_binary( find_packaged_binary("agentic-server") +def test_find_packaged_binary_finds_sibling_of_console_script( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + scripts_dir = tmp_path / "prefix" / "bin" + scripts_dir.mkdir(parents=True) + packaged_binary = scripts_dir / "agentic-server" + packaged_binary.write_text("#!/bin/sh\nexit 0\n") + packaged_binary.chmod(0o755) + console_script = scripts_dir / "agentic-api" + console_script.write_text("#!/bin/sh\nexit 0\n") + console_script.chmod(0o755) + + monkeypatch.setattr("agentic_api.binary.sysconfig.get_path", lambda name: str(tmp_path / "host" / "bin")) + monkeypatch.setattr("agentic_api.binary.sys.executable", str(tmp_path / "host" / "bin" / "python")) + monkeypatch.setattr("agentic_api.binary.sys.argv", [str(console_script)]) + monkeypatch.setattr("agentic_api.binary.shutil.which", lambda name: None) + + assert find_packaged_binary("agentic-server") == packaged_binary + + def test_read_binary_version_returns_first_line_from_version_output(tmp_path: Path) -> None: binary = tmp_path / "agentic-server" binary.write_text("#!/bin/sh\nprintf 'agentic-server 0.4.0\\nextra detail\\n'\n") From 5bf02a57deb6a497e03df8a7d2c9f853f561492f Mon Sep 17 00:00:00 2001 From: Francisco Javier Arceo Date: Wed, 26 Aug 2026 15:26:56 -0400 Subject: [PATCH 14/18] test: remove readiness timeout race Signed-off-by: Francisco Javier Arceo --- tests/python/test_process.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/python/test_process.py b/tests/python/test_process.py index acf3aea0..59108cf0 100644 --- a/tests/python/test_process.py +++ b/tests/python/test_process.py @@ -440,7 +440,7 @@ def test_wait_for_vllm_ready_retries_request_timeouts_without_leaking_api_key( models_server: tuple[ThreadingHTTPServer, str] ) -> None: _, base_url = models_server - ModelsHandler.response_delay_s = 0.2 + ModelsHandler.response_delay_s = 0.5 with pytest.raises(TimeoutError) as exc_info: wait_for_vllm_ready( From 32e4cfe53e8441753689418b33339bafc64f0492 Mon Sep 17 00:00:00 2001 From: Francisco Javier Arceo Date: Wed, 26 Aug 2026 15:50:55 -0400 Subject: [PATCH 15/18] fix: address Python launcher review feedback Signed-off-by: Francisco Javier Arceo --- README.md | 8 +++---- docs/guides/python-installation.md | 6 ++--- python/agentic_api/binary.py | 5 ++-- python/agentic_api/launcher.py | 21 ++++++++++------- tests/python/test_binary.py | 4 ++++ tests/python/test_installed_cli.py | 7 ++++++ tests/python/test_launcher.py | 37 +++++++++++++++++++++++------- 7 files changed, 63 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 670158f8..a18cf07f 100644 --- a/README.md +++ b/README.md @@ -122,9 +122,9 @@ permission checks and disables Codex approvals and sandboxing. ### Python distribution -The `agentic-api` wheel packages the Rust gateway and a small Python launcher. This release includes build-only wheel -artifacts: download the wheel for your platform from the release workflow, then install that local file. It is not -published on PyPI. +The `agentic-api` wheel packages the Rust gateway and a small Python launcher. This release includes 0.5.0 build-only +wheel artifacts: download the wheel for your platform from the release workflow, then install that local file. It is +not published on PyPI yet. ```bash WHEEL_PATH=/absolute/path/to/agentic_api-PLATFORM.whl @@ -143,7 +143,7 @@ script needs machine-readable diagnostics. #### After PyPI publication -These public-index and `uvx` examples apply only after the PyPI publication gate passes: +These public-index and `uvx` examples apply only after a future PyPI publication: ```bash uv pip install agentic-api diff --git a/docs/guides/python-installation.md b/docs/guides/python-installation.md index 69133b42..c1afd865 100644 --- a/docs/guides/python-installation.md +++ b/docs/guides/python-installation.md @@ -8,7 +8,7 @@ The Rust-native `agentic` CLI remains supported for `run codex`, `run claude`, ` ## Install the release artifact -This release produces wheel artifacts for supported platforms but does not publish them to PyPI. +This 0.5.0 release produces wheel artifacts for supported platforms but does not publish them to PyPI yet. Download the wheel for your platform from the release workflow and use its absolute path below: ```bash @@ -49,8 +49,8 @@ agentic-api serve --model Qwen/Qwen3-30B-A3B-FP8 -- \ ## After PyPI publication -The following public-index installation and `uvx` commands work after the PyPI publication gate has passed. They do -not work until the package is published: +The following public-index installation and `uvx` commands apply after a future PyPI publication. They do not work +until the package is published: ```bash uv pip install agentic-api diff --git a/python/agentic_api/binary.py b/python/agentic_api/binary.py index 49560033..bf35df84 100644 --- a/python/agentic_api/binary.py +++ b/python/agentic_api/binary.py @@ -61,13 +61,14 @@ def read_binary_version(path: Path) -> str: def _candidate_paths(name: str, *, include_ambient_path: bool = True) -> list[Path]: candidates: list[Path] = [] + if sys.argv and sys.argv[0]: + candidates.append(Path(sys.argv[0]).resolve().parent / name) + scripts_dir = sysconfig.get_path("scripts") if scripts_dir: candidates.append(Path(scripts_dir) / name) candidates.append(Path(sys.executable).resolve().parent / name) - if sys.argv and sys.argv[0]: - candidates.append(Path(sys.argv[0]).resolve().parent / name) if include_ambient_path: which_path = shutil.which(name) diff --git a/python/agentic_api/launcher.py b/python/agentic_api/launcher.py index e496e902..42fe228f 100644 --- a/python/agentic_api/launcher.py +++ b/python/agentic_api/launcher.py @@ -1,11 +1,10 @@ from __future__ import annotations import os -import secrets import signal import sys -from collections.abc import Sequence from importlib.metadata import PackageNotFoundError, version +from pathlib import Path from agentic_api.binary import ( PackagedBinaryNotFoundError, @@ -68,6 +67,7 @@ def _run_local_mode(supervisor: ProcessSupervisor, options: ServeOptions) -> Chi if options.model is None: raise ValueError("--model is required in local mode") + rust_binary = find_packaged_binary("agentic-server") vllm_path = find_active_environment_executable("vllm") installed_version = _installed_vllm_version() if installed_version != SUPPORTED_VLLM_VERSION: @@ -75,7 +75,7 @@ def _run_local_mode(supervisor: ProcessSupervisor, options: ServeOptions) -> Chi f"agentic-api local mode requires vllm=={SUPPORTED_VLLM_VERSION}; found {installed_version}" ) - vllm_api_key = os.environ.get(options.vllm_api_key_env) or secrets.token_urlsafe(24) + vllm_api_key = os.environ.get(options.vllm_api_key_env) vllm_url = f"http://127.0.0.1:{options.vllm_port}" vllm_process = supervisor.start( @@ -101,7 +101,7 @@ def _run_local_mode(supervisor: ProcessSupervisor, options: ServeOptions) -> Chi ) supervisor.start( - _rust_command(options, vllm_url), + _rust_command(options, vllm_url, binary=rust_binary), _rust_environment(options.gateway_api_key_env, vllm_api_key), ) return supervisor.wait_for_failure() @@ -118,12 +118,14 @@ def _run_remote_mode(supervisor: ProcessSupervisor, options: ServeOptions) -> Ch return supervisor.wait_for_failure() -def _rust_command(options: ServeOptions, upstream_base_url: str) -> list[str]: - binary = find_packaged_binary("agentic-server") +def _rust_command(options: ServeOptions, upstream_base_url: str, *, binary: Path | None = None) -> list[str]: + binary = binary or find_packaged_binary("agentic-server") return [ str(binary), "--llm-api-base", upstream_base_url, + "--llm-ready-timeout-s", + str(options.startup_timeout_s), "--gateway-host", options.host, "--gateway-port", @@ -141,9 +143,12 @@ def _rust_environment(gateway_api_key_env: str, api_key_override: str | None) -> return env -def _vllm_environment(api_key: str) -> dict[str, str]: +def _vllm_environment(api_key: str | None) -> dict[str, str]: env = os.environ.copy() - env["VLLM_API_KEY"] = api_key + if api_key is not None: + env["VLLM_API_KEY"] = api_key + else: + env.pop("VLLM_API_KEY", None) return env diff --git a/tests/python/test_binary.py b/tests/python/test_binary.py index d9186281..1c33f4c7 100644 --- a/tests/python/test_binary.py +++ b/tests/python/test_binary.py @@ -77,6 +77,10 @@ def test_find_packaged_binary_finds_sibling_of_console_script( packaged_binary = scripts_dir / "agentic-server" packaged_binary.write_text("#!/bin/sh\nexit 0\n") packaged_binary.chmod(0o755) + host_binary = tmp_path / "host" / "bin" / "agentic-server" + host_binary.parent.mkdir(parents=True) + host_binary.write_text("#!/bin/sh\nexit 0\n") + host_binary.chmod(0o755) console_script = scripts_dir / "agentic-api" console_script.write_text("#!/bin/sh\nexit 0\n") console_script.chmod(0o755) diff --git a/tests/python/test_installed_cli.py b/tests/python/test_installed_cli.py index d60b2efe..499343b7 100644 --- a/tests/python/test_installed_cli.py +++ b/tests/python/test_installed_cli.py @@ -22,7 +22,13 @@ def scripts_dir() -> Path: return Path(path).resolve() +def require_wheel_install() -> None: + if os.environ.get("AGENTIC_API_TEST_WHEEL") is None: + pytest.skip("set AGENTIC_API_TEST_WHEEL to exercise installed wheel commands") + + def test_installed_wheel_exposes_expected_commands() -> None: + require_wheel_install() directory = scripts_dir() for name in ("agentic-api", "agentic", "agentic-server"): @@ -32,6 +38,7 @@ def test_installed_wheel_exposes_expected_commands() -> None: def test_agentic_api_serve_uses_discovered_packaged_server_without_importing_vllm(tmp_path: Path) -> None: + require_wheel_install() directory = scripts_dir() server_path = directory / "agentic-server" backup_path = tmp_path / "agentic-server.real" diff --git a/tests/python/test_launcher.py b/tests/python/test_launcher.py index 52454d64..e775d98d 100644 --- a/tests/python/test_launcher.py +++ b/tests/python/test_launcher.py @@ -83,7 +83,8 @@ def test_run_serve_local_mode_starts_vllm_then_rust(monkeypatch: pytest.MonkeyPa monkeypatch.setattr(launcher, "find_packaged_binary", lambda name: Path("/pkg/bin/agentic-server")) monkeypatch.setattr(launcher, "find_active_environment_executable", lambda name: Path("/venv/bin/vllm")) monkeypatch.setattr(launcher, "_installed_vllm_version", lambda: "0.11.0") - monkeypatch.setattr(launcher.secrets, "token_urlsafe", lambda _: "generated-token") + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("AGENTIC_VLLM_API_KEY", raising=False) monkeypatch.setattr( launcher, "wait_for_vllm_ready", @@ -112,24 +113,45 @@ def test_run_serve_local_mode_starts_vllm_then_rust(monkeypatch: pytest.MonkeyPa "--port", "8000", ] - assert supervisor.starts[0][1]["VLLM_API_KEY"] == "generated-token" - assert "generated-token" not in supervisor.starts[0][0] + assert "VLLM_API_KEY" not in supervisor.starts[0][1] assert supervisor.starts[1][0] == [ "/pkg/bin/agentic-server", "--llm-api-base", "http://127.0.0.1:8000", + "--llm-ready-timeout-s", + "600.0", "--gateway-host", "0.0.0.0", "--gateway-port", "9000", ] - assert supervisor.starts[1][1]["OPENAI_API_KEY"] == "generated-token" - assert wait_calls == [("http://127.0.0.1:8000", "generated-token", 600.0, 2.0)] + assert "OPENAI_API_KEY" not in supervisor.starts[1][1] + assert wait_calls == [("http://127.0.0.1:8000", None, 600.0, 2.0)] assert signal.SIGINT in signal_handlers assert signal.SIGTERM in signal_handlers assert supervisor.terminate_timeout == 10.0 +def test_run_serve_local_mode_checks_packaged_gateway_before_starting_vllm( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + import agentic_api.launcher as launcher + + supervisor = FakeSupervisor() + monkeypatch.setattr(launcher, "ProcessSupervisor", lambda: supervisor) + monkeypatch.setattr( + launcher, + "find_packaged_binary", + lambda name: (_ for _ in ()).throw(launcher.PackagedBinaryNotFoundError("missing gateway")), + ) + monkeypatch.setattr(launcher, "find_active_environment_executable", lambda name: Path("/venv/bin/vllm")) + monkeypatch.setattr(launcher.signal, "signal", lambda sig, handler: handler) + + assert launcher.run_serve(make_options()) == 1 + assert supervisor.starts == [] + assert capsys.readouterr().err == "missing gateway\n" + + def test_run_serve_local_mode_returns_sigint_during_readiness_without_startup_error( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: @@ -142,7 +164,6 @@ def test_run_serve_local_mode_returns_sigint_during_readiness_without_startup_er monkeypatch.setattr(launcher, "find_packaged_binary", lambda name: Path("/pkg/bin/agentic-server")) monkeypatch.setattr(launcher, "find_active_environment_executable", lambda name: Path("/venv/bin/vllm")) monkeypatch.setattr(launcher, "_installed_vllm_version", lambda: "0.11.0") - monkeypatch.setattr(launcher.secrets, "token_urlsafe", lambda _: "generated-token") def fake_wait_for_vllm_ready( base_url: str, @@ -198,6 +219,8 @@ def test_run_serve_remote_mode_starts_only_rust_and_uses_selected_gateway_key( "/pkg/bin/agentic-server", "--llm-api-base", "https://upstream.example.com/base", + "--llm-ready-timeout-s", + "600.0", "--gateway-host", "0.0.0.0", "--gateway-port", @@ -218,7 +241,6 @@ def test_run_serve_reports_startup_failure_and_cleans_up_started_children( monkeypatch.setattr(launcher, "find_packaged_binary", lambda name: Path("/pkg/bin/agentic-server")) monkeypatch.setattr(launcher, "find_active_environment_executable", lambda name: Path("/venv/bin/vllm")) monkeypatch.setattr(launcher, "_installed_vllm_version", lambda: "0.11.0") - monkeypatch.setattr(launcher.secrets, "token_urlsafe", lambda _: "generated-token") monkeypatch.setattr( launcher, "wait_for_vllm_ready", @@ -262,7 +284,6 @@ def test_run_serve_reports_readiness_timeout_without_traceback( monkeypatch.setattr(launcher, "ProcessSupervisor", lambda: supervisor) monkeypatch.setattr(launcher, "find_active_environment_executable", lambda name: Path("/venv/bin/vllm")) monkeypatch.setattr(launcher, "_installed_vllm_version", lambda: "0.11.0") - monkeypatch.setattr(launcher.secrets, "token_urlsafe", lambda _: "generated-token") monkeypatch.setattr( launcher, "wait_for_vllm_ready", From abc0b7922dff9695e1966e10dc8d3564f8f0c27f Mon Sep 17 00:00:00 2001 From: Francisco Javier Arceo Date: Wed, 26 Aug 2026 15:52:27 -0400 Subject: [PATCH 16/18] fix: address launcher review feedback Signed-off-by: Francisco Javier Arceo --- README.md | 6 +++--- docs/guides/python-installation.md | 6 +++--- tests/python/test_launcher.py | 1 + 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a18cf07f..a3bb3abd 100644 --- a/README.md +++ b/README.md @@ -122,8 +122,8 @@ permission checks and disables Codex approvals and sandboxing. ### Python distribution -The `agentic-api` wheel packages the Rust gateway and a small Python launcher. This release includes 0.5.0 build-only -wheel artifacts: download the wheel for your platform from the release workflow, then install that local file. It is +The `agentic-api` wheel packages the Rust gateway and a small Python launcher. This release produces wheel artifacts +for 0.5.0 as a build-only release: download the wheel for your platform from the release workflow, then install that local file. It is not published on PyPI yet. ```bash @@ -143,7 +143,7 @@ script needs machine-readable diagnostics. #### After PyPI publication -These public-index and `uvx` examples apply only after a future PyPI publication: +These public-index and `uvx` examples apply only after the PyPI publication gate for a future release: ```bash uv pip install agentic-api diff --git a/docs/guides/python-installation.md b/docs/guides/python-installation.md index c1afd865..c4573973 100644 --- a/docs/guides/python-installation.md +++ b/docs/guides/python-installation.md @@ -8,7 +8,7 @@ The Rust-native `agentic` CLI remains supported for `run codex`, `run claude`, ` ## Install the release artifact -This 0.5.0 release produces wheel artifacts for supported platforms but does not publish them to PyPI yet. +This release produces wheel artifacts for 0.5.0 on supported platforms but does not publish them to PyPI yet. Download the wheel for your platform from the release workflow and use its absolute path below: ```bash @@ -49,8 +49,8 @@ agentic-api serve --model Qwen/Qwen3-30B-A3B-FP8 -- \ ## After PyPI publication -The following public-index installation and `uvx` commands apply after a future PyPI publication. They do not work -until the package is published: +The following public-index installation and `uvx` commands apply after the PyPI publication gate for a future release. +They do not work until the package is published: ```bash uv pip install agentic-api diff --git a/tests/python/test_launcher.py b/tests/python/test_launcher.py index e775d98d..17b283a8 100644 --- a/tests/python/test_launcher.py +++ b/tests/python/test_launcher.py @@ -282,6 +282,7 @@ def test_run_serve_reports_readiness_timeout_without_traceback( supervisor = FakeSupervisor() monkeypatch.setattr(launcher, "ProcessSupervisor", lambda: supervisor) + monkeypatch.setattr(launcher, "find_packaged_binary", lambda name: Path("/pkg/bin/agentic-server")) monkeypatch.setattr(launcher, "find_active_environment_executable", lambda name: Path("/venv/bin/vllm")) monkeypatch.setattr(launcher, "_installed_vllm_version", lambda: "0.11.0") monkeypatch.setattr( From 10efee4e205b24fc4d81ac6a5b9d83b812e9b4f1 Mon Sep 17 00:00:00 2001 From: Francisco Javier Arceo Date: Wed, 26 Aug 2026 16:17:13 -0400 Subject: [PATCH 17/18] test: update installed gateway command expectation Signed-off-by: Francisco Javier Arceo --- tests/python/test_installed_cli.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/python/test_installed_cli.py b/tests/python/test_installed_cli.py index 499343b7..c254a10f 100644 --- a/tests/python/test_installed_cli.py +++ b/tests/python/test_installed_cli.py @@ -124,6 +124,8 @@ def test_agentic_api_serve_uses_discovered_packaged_server_without_importing_vll "argv": [ "--llm-api-base", "https://upstream.example.test/base", + "--llm-ready-timeout-s", + "600.0", "--gateway-host", "127.0.0.1", "--gateway-port", From 26a4b0b7c1f5ddf27c48d2ee9082d6a259a65180 Mon Sep 17 00:00:00 2001 From: Francisco Javier Arceo Date: Fri, 28 Aug 2026 00:17:36 -0400 Subject: [PATCH 18/18] fix: address Copilot review feedback Signed-off-by: Francisco Javier Arceo --- .github/workflows/python.yml | 4 ++++ AGENTS.md | 2 +- python/agentic_api/binary.py | 2 +- python/agentic_api/diagnostics.py | 2 +- python/agentic_api/launcher.py | 5 +++++ tests/python/test_binary.py | 19 +++++++++++++++++++ tests/python/test_diagnostics.py | 27 +++++++++++++++++++++++++++ tests/python/test_launcher.py | 28 +++++++++++++++++++++++++++- 8 files changed, 85 insertions(+), 4 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 9aea2f9f..4c9bbdfc 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -10,6 +10,8 @@ on: - "Cargo.lock" - "Cargo.toml" - "crates/**" + - "README.md" + - "docs/**" - "pyproject.toml" - "python-build-constraints.txt" - "python/**" @@ -26,6 +28,8 @@ on: - "Cargo.lock" - "Cargo.toml" - "crates/**" + - "README.md" + - "docs/**" - "pyproject.toml" - "python-build-constraints.txt" - "python/**" diff --git a/AGENTS.md b/AGENTS.md index 4a2a9b7f..384237dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,7 +50,7 @@ cargo build cargo test # Python distribution and CLI tests -.venv/bin/python -m pytest tests/python +uv run --python 3.12 --with maturin==1.14.1 --with pytest==9.1.1 python -m pytest tests/python # Source-install CLI E2E (also exercises the packaged wheel build) python3 scripts/tests/agentic-cli-e2e-test.py diff --git a/python/agentic_api/binary.py b/python/agentic_api/binary.py index bf35df84..8db3c06f 100644 --- a/python/agentic_api/binary.py +++ b/python/agentic_api/binary.py @@ -28,7 +28,7 @@ def find_packaged_binary(name: str) -> Path: def find_active_environment_executable(name: str) -> Path: - for candidate in _candidate_paths(name): + for candidate in _candidate_paths(name, include_ambient_path=False): if _is_executable_file(candidate): return candidate raise FileNotFoundError(f"{name} executable not found in the active environment") diff --git a/python/agentic_api/diagnostics.py b/python/agentic_api/diagnostics.py index b4a2eb80..a04c6a3c 100644 --- a/python/agentic_api/diagnostics.py +++ b/python/agentic_api/diagnostics.py @@ -132,7 +132,7 @@ def _local_health( *, platform_name: str, ) -> tuple[bool, str]: - if platform_name != "Linux" and installed_vllm_version == "not installed": + if platform_name != "Linux": return ( False, "Local mode is currently supported only on Linux because the [local] extra installs vLLM only on Linux. " diff --git a/python/agentic_api/launcher.py b/python/agentic_api/launcher.py index 42fe228f..8e0e1c05 100644 --- a/python/agentic_api/launcher.py +++ b/python/agentic_api/launcher.py @@ -66,6 +66,11 @@ def begin_shutdown(signum: int, _frame: object) -> None: def _run_local_mode(supervisor: ProcessSupervisor, options: ServeOptions) -> ChildResult: if options.model is None: raise ValueError("--model is required in local mode") + if sys.platform != "linux": + raise RuntimeError( + "agentic-api local mode is currently supported only on Linux; " + "use remote mode on this platform" + ) rust_binary = find_packaged_binary("agentic-server") vllm_path = find_active_environment_executable("vllm") diff --git a/tests/python/test_binary.py b/tests/python/test_binary.py index 1c33f4c7..5af62e6b 100644 --- a/tests/python/test_binary.py +++ b/tests/python/test_binary.py @@ -6,6 +6,7 @@ from agentic_api.binary import ( PackagedBinaryNotFoundError, + find_active_environment_executable, find_packaged_binary, read_binary_version, ) @@ -93,6 +94,24 @@ def test_find_packaged_binary_finds_sibling_of_console_script( assert find_packaged_binary("agentic-server") == packaged_binary +def test_find_active_environment_executable_does_not_use_ambient_path( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + scripts_dir = tmp_path / "env" / "bin" + scripts_dir.mkdir(parents=True) + ambient_binary = tmp_path / "unrelated" / "vllm" + ambient_binary.parent.mkdir(parents=True) + ambient_binary.write_text("#!/bin/sh\nexit 0\n") + ambient_binary.chmod(0o755) + + monkeypatch.setattr("agentic_api.binary.sysconfig.get_path", lambda name: str(scripts_dir)) + monkeypatch.setattr("agentic_api.binary.sys.executable", str(tmp_path / "env" / "bin" / "python")) + monkeypatch.setattr("agentic_api.binary.shutil.which", lambda name: str(ambient_binary)) + + with pytest.raises(FileNotFoundError, match="active environment"): + find_active_environment_executable("vllm") + + def test_read_binary_version_returns_first_line_from_version_output(tmp_path: Path) -> None: binary = tmp_path / "agentic-server" binary.write_text("#!/bin/sh\nprintf 'agentic-server 0.4.0\\nextra detail\\n'\n") diff --git a/tests/python/test_diagnostics.py b/tests/python/test_diagnostics.py index 8d18e7bd..d2eea545 100644 --- a/tests/python/test_diagnostics.py +++ b/tests/python/test_diagnostics.py @@ -10,6 +10,11 @@ from agentic_api.compatibility import SUPPORTED_VLLM_VERSION +@pytest.fixture(autouse=True) +def default_to_linux_for_local_checks(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("agentic_api.diagnostics.platform.system", lambda: "Linux") + + def test_remote_doctor_is_healthy_without_vllm( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path ) -> None: @@ -109,6 +114,28 @@ def test_local_doctor_explains_linux_only_extra_on_unsupported_platform( assert "remote mode" in output +def test_local_doctor_rejects_supported_vllm_on_unsupported_platform( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path +) -> None: + rust_binary = tmp_path / "agentic-server" + rust_binary.write_text("") + rust_binary.chmod(0o755) + vllm_binary = tmp_path / "vllm" + vllm_binary.write_text("") + vllm_binary.chmod(0o755) + + monkeypatch.setattr("agentic_api.diagnostics.find_packaged_binary", lambda name: rust_binary) + monkeypatch.setattr("agentic_api.diagnostics.read_binary_version", lambda path: "agentic-server 0.5.0") + monkeypatch.setattr("agentic_api.diagnostics.metadata_version", _metadata_version_with_supported_vllm) + monkeypatch.setattr("agentic_api.diagnostics.find_active_environment_executable", lambda name: vllm_binary) + monkeypatch.setattr("agentic_api.diagnostics.platform.system", lambda: "Darwin") + + assert diagnostics.doctor("local") == 1 + output = capsys.readouterr().out + assert "Local mode health: unavailable" in output + assert "Local mode is currently supported only on Linux" in output + + def test_local_doctor_reports_incompatible_vllm_version( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path ) -> None: diff --git a/tests/python/test_launcher.py b/tests/python/test_launcher.py index 17b283a8..310cee85 100644 --- a/tests/python/test_launcher.py +++ b/tests/python/test_launcher.py @@ -69,8 +69,9 @@ def make_options(**overrides: Any) -> ServeOptions: @pytest.fixture(autouse=True) -def clear_fake_supervisors() -> None: +def clear_fake_supervisors(monkeypatch: pytest.MonkeyPatch) -> None: FakeSupervisor.instances.clear() + monkeypatch.setattr("agentic_api.launcher.sys.platform", "linux") def test_run_serve_local_mode_starts_vllm_then_rust(monkeypatch: pytest.MonkeyPatch) -> None: @@ -152,6 +153,31 @@ def test_run_serve_local_mode_checks_packaged_gateway_before_starting_vllm( assert capsys.readouterr().err == "missing gateway\n" +def test_run_serve_local_mode_rejects_unsupported_platform_before_discovery( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + import agentic_api.launcher as launcher + + supervisor = FakeSupervisor() + monkeypatch.setattr(launcher, "ProcessSupervisor", lambda: supervisor) + monkeypatch.setattr(launcher.sys, "platform", "darwin") + monkeypatch.setattr( + launcher, + "find_packaged_binary", + lambda name: (_ for _ in ()).throw(AssertionError("gateway discovery should not run")), + ) + monkeypatch.setattr( + launcher, + "find_active_environment_executable", + lambda name: (_ for _ in ()).throw(AssertionError("vLLM discovery should not run")), + ) + monkeypatch.setattr(launcher.signal, "signal", lambda sig, handler: handler) + + assert launcher.run_serve(make_options()) == 1 + assert supervisor.starts == [] + assert "local mode is currently supported only on Linux" in capsys.readouterr().err + + def test_run_serve_local_mode_returns_sigint_during_readiness_without_startup_error( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: