diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 00000000..4c9bbdfc --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,110 @@ +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/**" + - "README.md" + - "docs/**" + - "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/**" + - "README.md" + - "docs/**" + - "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 + components: clippy, rustfmt + + - name: Cache cargo registry and build + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + 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 + 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(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 new file mode 100644 index 00000000..3c57ce3c --- /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.5.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/AGENTS.md b/AGENTS.md index 9504be54..384237dd 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 +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 ``` - Before adding or updating replay cassettes, read `crates/agentic-server-core/tests/cassettes/README.md` and use its diff --git a/README.md b/README.md index eda147bb..22f70a96 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,42 @@ 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. 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 +WHEEL_PATH=/absolute/path/to/agentic_api-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 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. + +#### After 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 +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 56d0c100..b4261088 100644 --- a/crates/agentic-server/tests/cli_test.rs +++ b/crates/agentic-server/tests/cli_test.rs @@ -35,6 +35,42 @@ fn help_does_not_expose_database_url_credentials() { 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(concat!("agentic-server ", env!("CARGO_PKG_VERSION")))); +} + +#[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")); +} + #[test] fn agentic_cli_errors_redact_url_userinfo() { let output = Command::new(env!("CARGO_BIN_EXE_agentic")) diff --git a/docs/guides/python-installation.md b/docs/guides/python-installation.md new file mode 100644 index 00000000..c4573973 --- /dev/null +++ b/docs/guides/python-installation.md @@ -0,0 +1,100 @@ +# 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 release artifact + +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 +WHEEL_PATH=/absolute/path/to/agentic_api-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 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 `--`: + +```bash +agentic-api serve --model Qwen/Qwen3-30B-A3B-FP8 -- \ + --dtype bfloat16 \ + --max-model-len=32768 +``` + +## After PyPI publication + +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 +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. + +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 +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 +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..073f7bef 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 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 + ## 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..8db3c06f --- /dev/null +++ b/python/agentic_api/binary.py @@ -0,0 +1,90 @@ +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" +BINARY_VERSION_TIMEOUT_S = 5.0 + + +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: + 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}") + + +def find_active_environment_executable(name: str) -> Path: + 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") + + +def read_binary_version(path: Path) -> str: + try: + completed = subprocess.run( + [str(path), "--version"], + 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" + ) 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, *, 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 include_ambient_path: + 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..86a497b6 --- /dev/null +++ b/python/agentic_api/cli.py @@ -0,0 +1,223 @@ +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 import __version__ +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", + 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", allow_abbrev=False + ) + 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", 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" + ) + + 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": + 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, json_output=namespace.json_output) + + if namespace.command == "serve": + try: + from agentic_api.launcher import run_serve + 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) + + parser.error(f"unknown command: {namespace.command}") + return 1 + + +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") + + 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" + + 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, + 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 len(option_name) > 2 and any( + reserved.startswith(option_name) for reserved in RESERVED_VLLM_FLAGS | 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: + 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/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..a04c6a3c --- /dev/null +++ b/python/agentic_api/diagnostics.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import json +import os +import platform +from dataclasses import asdict, dataclass +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, + 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, *, json_output: bool = False) -> int: + report = collect_doctor_report() + print(render_doctor_report(report, mode, json_output=json_output)) + + 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.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, + platform_name=platform.system(), + ) + + 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, *, 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" + + 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, + *, + platform_name: str, +) -> tuple[bool, str]: + if platform_name != "Linux": + 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"`.' + ) + 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..8e0e1c05 --- /dev/null +++ b/python/agentic_api/launcher.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import os +import signal +import sys +from importlib.metadata import PackageNotFoundError, version +from pathlib import Path + +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 (OSError, 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") + 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") + 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) + 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), + ], + _vllm_environment(vllm_api_key), + ) + 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, binary=rust_binary), + _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, *, 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", + 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 _vllm_environment(api_key: str | None) -> dict[str, str]: + env = os.environ.copy() + if api_key is not None: + env["VLLM_API_KEY"] = api_key + else: + env.pop("VLLM_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: + 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) + + +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..cff8716a --- /dev/null +++ b/python/agentic_api/process.py @@ -0,0 +1,225 @@ +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 = 5.0 +_IS_POSIX = os.name == "posix" + + +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 _IS_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 _IS_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..48243145 --- /dev/null +++ b/scripts/check-python-wheel.sh @@ -0,0 +1,304 @@ +#!/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_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 +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( + name.endswith(f".data/scripts/{script_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/tests/agentic-cli-e2e-test.py b/scripts/tests/agentic-cli-e2e-test.py index 9d7999c3..4d86ea30 100755 --- a/scripts/tests/agentic-cli-e2e-test.py +++ b/scripts/tests/agentic-cli-e2e-test.py @@ -174,13 +174,95 @@ 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, expected_version: str) -> 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", f"import agentic_api; assert agentic_api.__version__ == {expected_version!r}"], + 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() != f"agentic-api {expected_version}": + 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")) 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, 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 new file mode 100755 index 00000000..913f342e --- /dev/null +++ b/scripts/validate-python-release-version.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +requested_version="${AGENTIC_API_RELEASE_VERSION:-}" +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_binary.py b/tests/python/test_binary.py new file mode 100644 index 00000000..5af62e6b --- /dev/null +++ b/tests/python/test_binary.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from agentic_api.binary import ( + PackagedBinaryNotFoundError, + find_active_environment_executable, + 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_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_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) + 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) + + 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_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") + 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) diff --git a/tests/python/test_cli.py b/tests/python/test_cli.py new file mode 100644 index 00000000..e1e355a5 --- /dev/null +++ b/tests/python/test_cli.py @@ -0,0 +1,276 @@ +from __future__ import annotations + +import builtins +from dataclasses import asdict + +import pytest + +from agentic_api import __version__ +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( + "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"), + [ + ([], "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 + + +@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", + "Qwen/Qwen3-4B", + "--", + "--dtype", + "bfloat16", + "--max-model-len=32768", + ) + + 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", + [ + ["--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"], + ["--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( + 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" + + +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 == f"agentic-api {__version__}\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)] + + +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" + ) diff --git a/tests/python/test_diagnostics.py b/tests/python/test_diagnostics.py new file mode 100644 index 00000000..d2eea545 --- /dev/null +++ b/tests/python/test_diagnostics.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +import importlib +import json +from pathlib import Path + +import pytest + +from agentic_api import diagnostics +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: + 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_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: + 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)), + ) + monkeypatch.setattr("agentic_api.diagnostics.platform.system", lambda: "Linux") + + 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 agentic-api wheel artifact with its local extra" in output + 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_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: + 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 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" + 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..7640afb3 --- /dev/null +++ b/tests/python/test_docs_examples.py @@ -0,0 +1,56 @@ +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 "This release produces wheel artifacts" 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 + 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..c254a10f --- /dev/null +++ b/tests/python/test_installed_cli.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import sysconfig +from pathlib import Path + +import pytest + +from agentic_api import __version__ + + +COMMAND_TIMEOUT_S = 20 + + +def scripts_dir() -> Path: + path = sysconfig.get_path("scripts") + assert path is not None + 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"): + 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: + require_wheel_install() + 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 == 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": [ + "--llm-api-base", + "https://upstream.example.test/base", + "--llm-ready-timeout-s", + "600.0", + "--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 f"agentic-api version: {__version__}" in result.stdout diff --git a/tests/python/test_launcher.py b/tests/python/test_launcher.py new file mode 100644 index 00000000..310cee85 --- /dev/null +++ b/tests/python/test_launcher.py @@ -0,0 +1,381 @@ +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(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: + 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.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("AGENTIC_VLLM_API_KEY", raising=False) + 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 == 1 + 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", + ] + 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 "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_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: + 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") + + 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", + "--llm-ready-timeout-s", + "600.0", + "--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, + "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_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: + 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, + "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 + + 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 == 1 + 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..61fc5f0b --- /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_matches_package_constant() -> None: + installed_distribution = distribution("agentic-api") + assert installed_distribution.version == __version__ + assert installed_distribution.metadata["Version"] == __version__ + 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..59108cf0 --- /dev/null +++ b/tests/python/test_process.py @@ -0,0 +1,483 @@ +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._IS_POSIX", False) + + 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_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]) + + 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.5 + + 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..2c441f1f --- /dev/null +++ b/tests/python/test_release_version.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import os +import re +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" +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"] = WORKSPACE_VERSION + + 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"{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 f"{WORKSPACE_VERSION} 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..274a33c2 --- /dev/null +++ b/tests/python/test_wheel_check.py @@ -0,0 +1,341 @@ +from __future__ import annotations + +import json +import os +import re +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" +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: + 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_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", + 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 / 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, + expected_version=WORKSPACE_VERSION, + ) + + assert result.returncode == 0, result.stderr + 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, + scripts_dir: Path, + *, + cargo_metadata_path: Path | None = None, + expected_wheel_tag: str | None = None, + cwd: Path = REPO_ROOT, + 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) + 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) + 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, + *, + 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" + entries = { + "agentic_api/__init__.py": f"__version__ = '{version}'\n", + f"{dist_info}/METADATA": textwrap.dedent( + f"""\ + Metadata-Version: 2.3 + Name: agentic-api + Version: {version} + """ + ), + f"{dist_info}/entry_points.txt": textwrap.dedent( + """\ + [console_scripts] + agentic-api = agentic_api.cli:main + """ + ), + } + entries.update({f"{data_dir}/scripts/{script_name}": "" for script_name in packaged_scripts}) + 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, *, version: str = EXPECTED_VERSION) -> Path: + package_dir = path / "agentic_api" + package_dir.mkdir(parents=True) + (package_dir / "__init__.py").write_text(f"__version__ = '{version}'\n", encoding="utf-8") + + 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: {version} + """ + ), + 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, *, 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 {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 {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: {version}") + print("Rust binary version: agentic-server {version}") + 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)