diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0dfc9a5..0e404ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,7 @@ on: - "packages/**" - "tests/**" - "scripts/**" + - "design/**" - "pyproject.toml" - "Dockerfile" - ".github/workflows/ci.yml" @@ -17,6 +18,7 @@ on: - "packages/**" - "tests/**" - "scripts/**" + - "design/**" - "pyproject.toml" - "Dockerfile" - ".github/workflows/ci.yml" @@ -40,6 +42,38 @@ jobs: - name: pytest run: PYTHONPATH=. pytest -v --tb=short + package: + name: package + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - run: python -m pip install --upgrade build twine + - name: build wheel + sdist + run: python -m build + - name: metadata is valid for PyPI + run: twine check --strict dist/* + - name: wheel installs, boots and serves the dashboard + run: | + python -m venv /tmp/verify + /tmp/verify/bin/pip install --quiet dist/*.whl + /tmp/verify/bin/orcarouter-lite --version + DATABASE_URL=sqlite+aiosqlite:////tmp/verify.db /tmp/verify/bin/orcarouter-lite --port 8123 & + pid=$! + trap 'kill $pid 2>/dev/null || true' EXIT + for _ in $(seq 1 20); do + curl -sf http://localhost:8123/health >/dev/null && break + sleep 2 + done + curl -sf http://localhost:8123/health || { echo "::error::wheel did not boot"; exit 1; } + # The SPA only ships if pyproject force-includes design/ into app/design. + curl -sf http://localhost:8123/ | grep -qi " `:edge` image (amd64 only, fast feedback). +# Push a v* tag -> `:latest` + `:X.Y.Z` + `:X.Y` image (amd64 + arm64), +# PyPI release, and the dists attached to the GitHub release. +# +# Nothing consumable is published before it has been executed: the build pushes +# an immutable `sha-` tag, every platform in the image is booted against +# `/health`, and only then are the release tags moved onto that digest. +# +# One-time setup before the first tag: see RELEASING.md. + +on: + push: + branches: [main] + tags: ["v*"] + workflow_dispatch: + +concurrency: + # All tag releases share one group. Per-ref groups let a v0.1.1 and a v0.1.2 + # push run concurrently, and both write the shared `latest` tag — last writer + # wins, so a slower older release can leave `latest` pointing backwards. + # Branch and dispatch runs keep their own group. + group: release-${{ github.ref_type == 'tag' && 'tag' || github.ref }} + cancel-in-progress: false + +env: + REGISTRY: ghcr.io + +jobs: + image: + name: ghcr image + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + packages: write # push to ghcr.io + id-token: write # provenance attestation + attestations: write # provenance attestation + steps: + - uses: actions/checkout@v4 + + - name: resolve image name + platforms + id: cfg + run: | + # GHCR rejects uppercase; Continuum-AI-Corp/OrcaRouter-Lite -> lowercase. + image="${REGISTRY}/${GITHUB_REPOSITORY,,}" + echo "image=$image" >> "$GITHUB_OUTPUT" + # The build pushes this tag and nothing else. It is immutable and + # nothing consumes it, so a failed smoke test leaves no broken + # `latest`/`X.Y.Z` behind — `promote` applies those afterwards. + # Same shape as metadata-action's `type=sha,format=short`, so promote + # re-applies it with the rest and every tag lands on one digest. + echo "staging=${image}:sha-${GITHUB_SHA:0:7}" >> "$GITHUB_OUTPUT" + # arm64 is emulated (slow), so only pay for it on an actual release. + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + echo "platforms=linux/amd64,linux/arm64" >> "$GITHUB_OUTPUT" + else + echo "platforms=linux/amd64" >> "$GITHUB_OUTPUT" + fi + + - uses: docker/setup-qemu-action@v3 + if: startsWith(github.ref, 'refs/tags/v') + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: image tags + labels + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ steps.cfg.outputs.image }} + # This is the *promote* list, not what the build pushes: these tags + # are attached to the digest only after the smoke test passes. + tags: | + type=raw,value=edge,enable={{is_default_branch}} + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=sha,format=short + + - name: build + push + id: build + uses: docker/build-push-action@v6 + with: + context: . + platforms: ${{ steps.cfg.outputs.platforms }} + push: true + tags: ${{ steps.cfg.outputs.staging }} + labels: ${{ steps.meta.outputs.labels }} + annotations: ${{ steps.meta.outputs.annotations }} + cache-from: type=gha + cache-to: type=gha,mode=max + # Provenance is attached below by actions/attest-build-provenance so + # `gh attestation verify` works; buildx's own would duplicate it. + provenance: false + sbom: false + + - name: smoke the pushed image + run: | + img="${{ steps.cfg.outputs.image }}@${{ steps.build.outputs.digest }}" + host="linux/$(docker version --format '{{.Server.Arch}}')" + IFS=',' read -ra platforms <<< "${{ steps.cfg.outputs.platforms }}" + rc=0 + for p in "${platforms[@]}"; do + # Every platform that gets published is booted once. An arm64 image + # that is never executed is an arm64 image nobody proved works. + name="rel-${p##*/}" + # Emulated platforms boot several times slower than the host one. + if [ "$p" = "$host" ]; then tries=20; else tries=60; fi + echo "::group::smoke $p" + docker rm -f "$name" >/dev/null 2>&1 || true + docker run -d --name "$name" --platform "$p" -p 8000:8000 \ + -e DATABASE_URL=sqlite+aiosqlite:///./orca.db "$img" >/dev/null + ok=0 + for _ in $(seq 1 "$tries"); do + if curl -sf http://localhost:8000/health; then + echo; echo "✓ $p is healthy" + ok=1 + break + fi + sleep 3 + done + if [ "$ok" != 1 ]; then + echo "✗ $p never became healthy" + docker logs "$name" || true + rc=1 + fi + docker rm -f "$name" >/dev/null + echo "::endgroup::" + [ "$rc" = 0 ] || break + done + exit $rc + + - name: promote the digest to the release tags + id: promote + run: | + src="${{ steps.cfg.outputs.image }}@${{ steps.build.outputs.digest }}" + released="${{ steps.build.outputs.digest }}" + tags=() + while IFS= read -r t; do + [ -n "$t" ] || continue + tags+=("$t") + done <<< "${{ steps.meta.outputs.tags }}" + if [ ${#tags[@]} -eq 0 ]; then + echo "no tags for this ref — $src stays as it is" + else + args=() + for t in "${tags[@]}"; do args+=(--tag "$t"); done + docker buildx imagetools create "${args[@]}" "$src" + # imagetools copies a multi-platform index as-is but wraps a + # single-platform manifest in a fresh one, so read back what the + # tags actually resolve to instead of assuming the build digest — + # that is the digest consumers get, and the digest to attest. + released=$(docker buildx imagetools inspect --format '{{.Manifest.Digest}}' "${tags[0]}") + for t in "${tags[@]}"; do + got=$(docker buildx imagetools inspect --format '{{.Manifest.Digest}}' "$t") + if [ "$got" != "$released" ]; then + echo "::error::$t resolved to $got, expected $released — tags disagree" + exit 1 + fi + echo "✓ $t -> $got" + done + fi + echo "digest=$released" >> "$GITHUB_OUTPUT" + + # Attests what the release tags resolve to, so + # `gh attestation verify oci://…:latest` verifies the image people pull. + - name: attest build provenance + uses: actions/attest-build-provenance@v2 + with: + subject-name: ${{ steps.cfg.outputs.image }} + subject-digest: ${{ steps.promote.outputs.digest }} + push-to-registry: true + + - name: summary + run: | + { + echo "### Image published" + echo + echo '```' + echo "${{ steps.meta.outputs.tags }}" + echo '```' + echo + echo "digest: \`${{ steps.promote.outputs.digest }}\`" + echo "platforms: \`${{ steps.cfg.outputs.platforms }}\` (each one booted)" + } >> "$GITHUB_STEP_SUMMARY" + + pypi: + name: pypi + # The image job is the gate: a tag whose container never booted is not worth + # putting on PyPI, and publishing one of the two advertised artifacts for a + # version while the other failed leaves them inconsistent. + needs: image + # Forks can rehearse the whole release (the image job pushes to their own + # ghcr namespace), but only the canonical repo owns the PyPI project — a + # fork has no trusted publisher and would just fail at the OIDC exchange. + if: >- + startsWith(github.ref, 'refs/tags/v') + && github.repository == 'Continuum-AI-Corp/OrcaRouter-Lite' + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: + name: pypi + url: https://pypi.org/p/orcarouter-lite + permissions: + contents: write # attach the dists to the GitHub release + id-token: write # PyPI trusted publishing (OIDC) — no API token stored + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: tag must match the pyproject version + run: | + v=$(python -c 'import tomllib;print(tomllib.load(open("pyproject.toml","rb"))["project"]["version"])') + t="${GITHUB_REF_NAME#v}" + echo "pyproject=$v tag=$t" + if [ "$v" != "$t" ]; then + echo "::error::pyproject version ($v) does not match the tag ($t) — bump one of them" + exit 1 + fi + + - run: python -m pip install --upgrade build twine + + - run: python -m build + + - run: twine check --strict dist/* + + - name: wheel installs, boots and serves the dashboard + run: | + python -m venv /tmp/verify + /tmp/verify/bin/pip install --quiet dist/*.whl + /tmp/verify/bin/orcarouter-lite --version + DATABASE_URL=sqlite+aiosqlite:////tmp/verify.db \ + /tmp/verify/bin/orcarouter-lite --port 8123 & + pid=$! + trap 'kill $pid 2>/dev/null || true' EXIT + for _ in $(seq 1 20); do + curl -sf http://localhost:8123/health >/dev/null && break + sleep 2 + done + curl -sf http://localhost:8123/health || { echo "::error::wheel did not boot"; exit 1; } + curl -sf http://localhost:8123/ | grep -qi "/dev/null 2>&1 \ + || gh release create "$GITHUB_REF_NAME" --generate-notes + gh release upload "$GITHUB_REF_NAME" dist/* --clobber + + - name: publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + # Makes a re-run of a partially failed release a no-op here instead of + # a hard "File already exists" abort. The tag/version gate above is + # what keeps this from masking a genuine version mix-up. + skip-existing: true diff --git a/Dockerfile b/Dockerfile index 4d4e8cc..537fa0b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,13 +13,23 @@ RUN apt-get update \ WORKDIR /app COPY pyproject.toml . -RUN mkdir -p app packages \ - && pip install --no-cache-dir --upgrade pip \ - && pip install --no-cache-dir "." + +# Install the runtime dependencies only, read straight out of pyproject. The +# project itself is deliberately NOT installed here — the runtime stage puts +# the source on PYTHONPATH instead — so this layer is invalidated only when +# the dependency list changes, never on a code or README edit. +RUN pip install --no-cache-dir --upgrade pip \ + && python -c 'import tomllib;f=open("pyproject.toml","rb");print(*tomllib.load(f)["project"]["dependencies"],sep=chr(10))' > /tmp/requirements.txt \ + && pip install --no-cache-dir -r /tmp/requirements.txt # ── Runtime stage ───────────────────────────────────── FROM python:3.12-slim +LABEL org.opencontainers.image.title="OrcaRouter Lite" \ + org.opencontainers.image.description="Self-hosted LLM router with a managed safety net. OpenAI-compatible, BYOK." \ + org.opencontainers.image.source="https://github.com/Continuum-AI-Corp/OrcaRouter-Lite" \ + org.opencontainers.image.licenses="MIT" + WORKDIR /app COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..b2bec24 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,183 @@ +# Releasing + +OrcaRouter Lite ships three artifacts. `.github/workflows/release.yml` publishes +the two automatable ones; the Railway template needs a Railway account and stays +manual. This file covers the one-time setup each one needs and the steps to cut a +release. + +| Artifact | Where | Published by | +|---|---|---| +| Container image | `ghcr.io/continuum-ai-corp/orcarouter-lite` | `release.yml` — `image` job | +| Python package | [pypi.org/p/orcarouter-lite](https://pypi.org/p/orcarouter-lite) | `release.yml` — `pypi` job | +| Railway template | [railway.com](https://railway.com) marketplace | manual, see below | + +--- + +## One-time setup + +### 1. PyPI — trusted publishing + +The `pypi` job authenticates over OIDC, so there is no API token to store or +rotate. It will fail until PyPI knows about this repo. + +At , add a **pending publisher**: + +| Field | Value | +|---|---| +| PyPI project name | `orcarouter-lite` | +| Owner | `Continuum-AI-Corp` | +| Repository name | `OrcaRouter-Lite` | +| Workflow name | `release.yml` | +| Environment name | `pypi` | + +The environment name has to match the `environment: name: pypi` block in the +workflow. GitHub creates that environment on the first run; add reviewers to it +under **Settings → Environments** if releases should need approval. + +`orcarouter-lite` was unclaimed on PyPI as of the last check — confirm it still +is before the first publish, because the name cannot be changed afterwards +without renaming the distribution. + +### 2. GHCR — package visibility and repo link + +The first push creates the package as **private**, owned by the org. Make it +public once: + +**github.com/orgs/Continuum-AI-Corp/packages → orcarouter-lite → Package +settings → Change visibility → Public.** + +While there, under **Manage Actions access**, confirm the `OrcaRouter-Lite` +repository has `Write` — that is what lets `GITHUB_TOKEN` push on later runs. +The `org.opencontainers.image.source` label in the Dockerfile is what links the +package back to this repo on the GHCR page. + +No secrets are needed: the job uses the built-in `GITHUB_TOKEN` with +`packages: write`. + +### 3. Railway — publish the template + +Not wired up yet, and the button the READMEs carry today is broken: it points at +`https://railway.app/new/template`, a URL with no template behind it, so it +lands the visitor on the generic marketplace page and deploys nothing. Railway +serves a real one-click deploy only for a published **template code** +(`https://railway.com/new/template/`); the legacy `?template=` +form no longer resolves either. + +Codes cannot be minted from a repo URL — Railway's `templateGenerate` snapshots +an existing project, so the project has to be deployed by hand once: + +1. **railway.com -> New Project -> Deploy from GitHub repo -> OrcaRouter-Lite.** + `railway.json` already supplies the Dockerfile build, the `/health` check and + the restart policy. +2. Add a **Volume** mounted at `/data` and set + `DATABASE_URL=sqlite+aiosqlite:////data/orca.db`, plus + `CREDENTIAL_ENCRYPTION_KEY` and `API_KEY_PEPPER` (`openssl rand -hex 32`). + Without the volume every redeploy wipes provider keys, API keys and + analytics — and whatever this project looks like is exactly what one-click + deployers get, volume included. +3. **project -> ... -> Create Template from Project**, then publish. Railway + returns a code, e.g. `ZweBXA`. +4. Replace the Railway cell in all twelve `README*.md` files with: + + ```md + | Railway | [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/new/template/YOURCODE?utm_medium=integration&utm_source=button&utm_campaign=orcarouter-lite) | + ``` + +Steps 3 and 4 are scriptable against Railway's public API (`templateGenerate` + +`templatePublish` both accept an account token); that tooling is not part of +this change. + +--- + +## Cutting a release + +1. Bump `version` in `pyproject.toml`. The `pypi` job refuses to publish if it + does not match the tag, so this is the only place the number lives. +2. Merge to `main`. That publishes `ghcr.io/…:edge` (amd64) — a good check that + the image job is healthy before you tag. +3. Tag and push: + + ```bash + git tag -a v0.1.1 -m "v0.1.1" + git push origin v0.1.1 + ``` + +That produces: + +- `ghcr.io/continuum-ai-corp/orcarouter-lite:latest`, `:0.1.1`, `:0.1`, + `:sha-` — linux/amd64 + linux/arm64, with a provenance attestation + (`gh attestation verify oci://… --repo Continuum-AI-Corp/OrcaRouter-Lite`) +- `orcarouter-lite 0.1.1` on PyPI (wheel + sdist) +- both dists attached to the GitHub release + +Before publishing, the `pypi` job installs the wheel into a clean venv, boots +it, and fails the release if `/health` or the dashboard at `/` does not answer. +The `image` job runs the same smoke test against the pushed image. + +### What gates what + +Nothing a consumer can pull is published before it has been executed, and the +whole run is safe to re-run: + +- The build pushes **only** `:sha-` — immutable and unadvertised. Every + platform in that digest is then booted against `/health` (arm64 under qemu, + with a longer timeout), and only after that does a `promote` step move + `latest`/`X.Y.Z`/`X.Y`/`edge` onto the same digest with + `docker buildx imagetools create`. A failed smoke leaves the old `latest` + untouched. The provenance attestation is then made against the digest those + tags actually resolve to — read back, not assumed — so + `gh attestation verify oci://…:latest` verifies the image people pull. +- All tag releases share one concurrency group, so two versions tagged close + together cannot both write `latest` and leave it pointing at the older one. +- `pypi` `needs: image`, so a container that never booted stops the PyPI + release too — the two artifacts for a version ship together or not at all. +- Inside `pypi`, the GitHub release is created and the dists attached **before** + the PyPI upload. That is the retryable order: a published PyPI version can + never be replaced, so if the upload came first, a failure afterwards would be + unrecoverable by re-run. `skip-existing: true` lets a re-run pass through a + version that already landed. + +### The already-tagged v0.1.0 + +`v0.1.0` was tagged before this workflow existed, and it cannot be released +as-is. `workflow_dispatch` runs the workflow file *from the ref you select*, and +that tag carries only `ci.yml` and `benchmark.yml` — there is no `release.yml` +at `v0.1.0` for the dispatch to run. + +Cut `v0.1.1` instead. (Force-moving the `v0.1.0` tag onto a commit that has the +workflow would also work — nothing has consumed that tag yet — but re-pointing a +published tag is not a habit worth starting.) + +### Rehearsing on a fork + +The `image` job pushes to `ghcr.io/`, so a fork can +exercise the whole thing against its own namespace with no secrets: enable +Actions on the fork, then either open a pull request (runs `ci.yml`, which +builds the image and boots it without pushing) or push to the fork's `main` +(runs `release.yml`, which publishes `:edge`). The `pypi` job is scoped to +`Continuum-AI-Corp`, so it skips on forks rather than failing at the OIDC +exchange. + +Note that the **Run workflow** button only appears for workflows that exist on +the repository's default branch — until `release.yml` is merged to `main`, there +is nothing to dispatch. + +## Local checks + +CI's `package` job runs these on every PR, but to reproduce it: + +```bash +python -m build +twine check --strict dist/* + +python -m venv /tmp/verify +/tmp/verify/bin/pip install dist/*.whl +/tmp/verify/bin/orcarouter-lite --port 8123 & +curl -sf localhost:8123/health # {"status":"ok"} +curl -sf localhost:8123/ | head -1 # — dashboard came along +``` + +The last line is the one that matters: `design/` lives at the repo root but is +force-included into the wheel as `app/design/` (see the comment above +`[tool.hatch.build.targets.wheel]` in `pyproject.toml`). If that mapping is +dropped, everything still builds and boots — only the UI silently disappears. diff --git a/app/__main__.py b/app/__main__.py new file mode 100644 index 0000000..ed4484a --- /dev/null +++ b/app/__main__.py @@ -0,0 +1,6 @@ +"""`python -m app` — same entry point as the `orcarouter-lite` console script.""" + +from app.cli import main + +if __name__ == "__main__": + main() diff --git a/app/cli.py b/app/cli.py new file mode 100644 index 0000000..180a1d0 --- /dev/null +++ b/app/cli.py @@ -0,0 +1,65 @@ +"""Console entry point for `orcarouter-lite` (and `python -m app`). + +`scripts/start.py`, the Docker CMD and the pip-installed console script all +funnel through here, so a checkout, a container and `pip install +orcarouter-lite` boot the server the same way. +""" + +from __future__ import annotations + +import argparse +import os + + +def _version() -> str: + from importlib.metadata import PackageNotFoundError, version + + try: + return version("orcarouter-lite") + except PackageNotFoundError: # running straight from a checkout + return "(source checkout)" + + +def _build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="orcarouter-lite", + description="Self-hosted LLM router with a managed safety net — OpenAI-compatible, BYOK.", + ) + p.add_argument( + "--host", + default=os.environ.get("HOST", "0.0.0.0"), + help="bind address (env: HOST, default: 0.0.0.0)", + ) + p.add_argument( + "--port", + type=int, + default=int(os.environ.get("PORT", "8000")), + help="bind port (env: PORT, default: 8000)", + ) + p.add_argument( + "--log-level", + default=os.environ.get("LOG_LEVEL", "info"), + choices=["critical", "error", "warning", "info", "debug", "trace"], + help="uvicorn log level (env: LOG_LEVEL, default: info)", + ) + p.add_argument( + "--reload", + action="store_true", + help="restart on code changes (development only)", + ) + p.add_argument("--version", action="version", version=f"orcarouter-lite {_version()}") + return p + + +def main(argv: list[str] | None = None) -> None: + import uvicorn + + args = _build_parser().parse_args(argv) + uvicorn.run( + "app.main:app", + host=args.host, + port=args.port, + log_level=args.log_level, + reload=args.reload, + access_log=True, + ) diff --git a/app/main.py b/app/main.py index caec121..30471d1 100644 --- a/app/main.py +++ b/app/main.py @@ -7,6 +7,7 @@ from __future__ import annotations import logging +import os from collections.abc import AsyncGenerator from contextlib import asynccontextmanager @@ -16,6 +17,25 @@ from fastapi.responses import JSONResponse +def _find_design_dir() -> str | None: + """Locate the dashboard SPA. Three layouts have to work: + + * ``$ORCA_DESIGN_DIR`` — explicit override (custom build of the SPA) + * ``app/design`` — installed wheel; pyproject force-includes the + repo-root ``design/`` tree to this path + * ``../design`` — repo checkout and the Docker image + """ + here = os.path.dirname(os.path.abspath(__file__)) + for candidate in ( + os.environ.get("ORCA_DESIGN_DIR"), + os.path.join(here, "design"), + os.path.join(here, os.pardir, "design"), + ): + if candidate and os.path.isdir(candidate): + return candidate + return None + + @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: from sqlalchemy.ext.asyncio import async_sessionmaker @@ -145,13 +165,11 @@ async def unhandled(_req, exc: Exception): app.include_router(quality.router) # ── Static SPA (provider keys, routing, analytics, keys) ── - import os - from fastapi.responses import FileResponse, RedirectResponse from fastapi.staticfiles import StaticFiles - design_dir = os.path.join(os.path.dirname(__file__), "..", "design") - if os.path.isdir(design_dir): + design_dir = _find_design_dir() + if design_dir: app.mount("/static", StaticFiles(directory=design_dir), name="static") @app.get("/", include_in_schema=False) diff --git a/pyproject.toml b/pyproject.toml index 81a5332..9c65969 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,8 +2,29 @@ name = "orcarouter-lite" version = "0.1.0" description = "Self-hosted LLM router with a managed safety net. OpenAI-compatible, BYOK, single-workspace edition of OrcaRouter." +readme = "README.md" requires-python = ">=3.11" -license = { text = "MIT" } +license = "MIT" +license-files = ["LICENSE"] +authors = [{ name = "Continuum AI Corp" }] +keywords = [ + "llm", "router", "proxy", "gateway", "openai", "anthropic", "gemini", + "litellm", "self-hosted", "byok", "fastapi", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Web Environment", + "Framework :: FastAPI", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Internet :: Proxy Servers", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Typing :: Typed", +] dependencies = [ "fastapi>=0.115.0", @@ -39,12 +60,32 @@ dev = [ "ruff>=0.4.0", ] +[project.scripts] +orcarouter-lite = "app.cli:main" + +[project.urls] +Homepage = "https://www.orcarouter.ai" +Repository = "https://github.com/Continuum-AI-Corp/OrcaRouter-Lite" +Issues = "https://github.com/Continuum-AI-Corp/OrcaRouter-Lite/issues" +Changelog = "https://github.com/Continuum-AI-Corp/OrcaRouter-Lite/releases" + [build-system] -requires = ["setuptools>=69.0"] -build-backend = "setuptools.build_meta" +requires = ["hatchling>=1.27"] +build-backend = "hatchling.build" + +# `app` and `packages` are the two importable roots; `design/` is the dashboard +# SPA, which lives at the repo root for dev/Docker but has to travel inside the +# wheel so `pip install orcarouter-lite` still serves the UI. force-include maps +# it to `app/design/` at build time — no file move, no drift in the package list. +[tool.hatch.build.targets.wheel] +packages = ["app", "packages"] + +[tool.hatch.build.targets.wheel.force-include] +"design" = "app/design" -[tool.setuptools.packages.find] -include = ["app*", "packages*"] +[tool.hatch.build.targets.sdist] +include = ["app", "packages", "design", "scripts", "tests", "README.md", "LICENSE", "pyproject.toml"] +exclude = ["**/__pycache__", "**/*.pyc", "design/test_i18n_unittest.py"] [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/scripts/start.py b/scripts/start.py index 3861b98..714f9af 100644 --- a/scripts/start.py +++ b/scripts/start.py @@ -1,19 +1,18 @@ -"""Production-style boot — uvicorn with sane defaults.""" +"""Production-style boot — uvicorn with sane defaults. -import os +Thin wrapper so `python scripts/start.py` (Docker CMD, Railway / Fly start +command, the benchmark workflow) keeps working. The logic lives in `app.cli` +because `scripts/` is not part of the wheel — only `app/` and `packages/` are. +""" -import uvicorn +import sys +from pathlib import Path +# Docker and CI set PYTHONPATH explicitly, but a bare `python scripts/start.py` +# only puts `scripts/` on sys.path — add the repo root so `app` is importable. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -def main() -> None: - uvicorn.run( - "app.main:app", - host=os.environ.get("HOST", "0.0.0.0"), - port=int(os.environ.get("PORT", "8000")), - log_level=os.environ.get("LOG_LEVEL", "info"), - access_log=True, - ) - +from app.cli import main # noqa: E402 if __name__ == "__main__": main() diff --git a/tests/unit/test_packaging.py b/tests/unit/test_packaging.py new file mode 100644 index 0000000..93fd244 --- /dev/null +++ b/tests/unit/test_packaging.py @@ -0,0 +1,80 @@ +"""Tripwires for the distribution artifacts. + +`pip install orcarouter-lite` has to produce a server that boots *and* serves +the dashboard. Three pieces make that work, and none of them are touched by the +rest of the suite: + + * the `orcarouter-lite` console script -> `app.cli:main` + * pyproject force-including the repo-root `design/` tree into `app/design/` + * `app.main._find_design_dir()` preferring that installed location + +CI's `package` job runs the real end-to-end check (build the wheel, install it +into a clean venv, boot it, curl `/`). These are the cheap unit-level guards so +a regression shows up in the normal test run instead of at release time. +""" + +from __future__ import annotations + +import tomllib +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +@pytest.fixture(scope="module") +def pyproject() -> dict: + with (REPO_ROOT / "pyproject.toml").open("rb") as f: + return tomllib.load(f) + + +def test_console_script_points_at_a_real_callable(pyproject: dict) -> None: + assert pyproject["project"]["scripts"]["orcarouter-lite"] == "app.cli:main" + + from app.cli import main + + assert callable(main) + + +def test_cli_accepts_host_port_and_log_level() -> None: + from app.cli import _build_parser + + args = _build_parser().parse_args( + ["--host", "127.0.0.1", "--port", "9999", "--log-level", "debug"] + ) + assert (args.host, args.port, args.log_level) == ("127.0.0.1", 9999, "debug") + + +def test_wheel_carries_the_dashboard(pyproject: dict) -> None: + """Drop this mapping and the wheel ships an API with no UI behind `/`.""" + wheel = pyproject["tool"]["hatch"]["build"]["targets"]["wheel"] + assert wheel["packages"] == ["app", "packages"] + assert wheel["force-include"]["design"] == "app/design" + + +def test_design_dir_resolves_in_a_repo_checkout() -> None: + from app.main import _find_design_dir + + found = _find_design_dir() + assert found is not None + assert Path(found).resolve() == (REPO_ROOT / "design").resolve() + assert (Path(found) / "index.html").is_file() + + +def test_design_dir_honours_the_env_override(tmp_path: Path, monkeypatch) -> None: + (tmp_path / "index.html").write_text("", encoding="utf-8") + monkeypatch.setenv("ORCA_DESIGN_DIR", str(tmp_path)) + + from app.main import _find_design_dir + + assert Path(_find_design_dir()).resolve() == tmp_path.resolve() + + +def test_start_script_delegates_to_the_cli() -> None: + """Docker CMD runs scripts/start.py, the wheel runs app.cli — one code path. + + `scripts/` is not part of the wheel, so the boot logic cannot live there. + """ + source = (REPO_ROOT / "scripts" / "start.py").read_text(encoding="utf-8") + assert "from app.cli import main" in source