diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c4656fc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +.git +.github +.claude +.codex +.env +.env.* +.venv +.venv-check +.venv-check-313 +venv +__pycache__ +.pycache +.pytest_cache +.ruff_cache +build +dist +*.egg-info +*.db +*.db-* +*.sqlite* +*.log diff --git a/.env.example b/.env.example index 4df18e4..32db166 100644 --- a/.env.example +++ b/.env.example @@ -4,11 +4,13 @@ DEEPSEEK_API_KEY=sk-your-deepseek-key GEMINI_API_KEY=AIza-your-gemini-key OPENROUTER_API_KEY=sk-or-your-openrouter-key +OPENAI_API_KEY=sk-your-openai-key # Optional overrides # DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 # GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta # OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 +# OPENAI_BASE_URL=https://api.openai.com/v1 # Default from config.yaml: /var/lib/foundrygate/foundrygate.db # Example for local non-root runs: # FOUNDRYGATE_DB_PATH=/home/you/.local/state/foundrygate/foundrygate.db diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..6e73b4a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,38 @@ +name: Bug report +description: Report a reproducible problem in FoundryGate +title: "[Bug]: " +labels: ["bug"] +body: + - type: textarea + id: summary + attributes: + label: Summary + description: What happened, and what did you expect instead? + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Reproduction + description: Include request payloads, config snippets, logs, or curl commands where relevant. + placeholder: | + 1. Start FoundryGate with ... + 2. Send this request ... + 3. Observe ... + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment + description: Version, Python version, OS, provider config, and whether you run locally, systemd, or Docker. + validations: + required: true + - type: textarea + id: diagnostics + attributes: + label: Diagnostics + description: Paste `/health`, `/api/route`, trace output, or logs if they help. + - type: markdown + attributes: + value: "For vulnerabilities or secrets exposure, do not file a public issue. Use the security policy in `SECURITY.md`." diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..8fa271b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/typelicious/FoundryGate/security/policy + about: Use the private security reporting path described in SECURITY.md diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..94197e3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,38 @@ +name: Feature request +description: Suggest a product, routing, or operator-facing improvement +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What operator, client, or provider problem should this solve? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposal + description: Describe the change and any expected API, config, or UX impact. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Mention any simpler or current workarounds you already tried. + - type: dropdown + id: area + attributes: + label: Area + options: + - routing + - providers + - observability + - docker / distribution + - security + - docs + - integrations + validations: + required: true diff --git a/.github/RELEASE_TEMPLATE.md b/.github/RELEASE_TEMPLATE.md index a61b05d..70576a1 100644 --- a/.github/RELEASE_TEMPLATE.md +++ b/.github/RELEASE_TEMPLATE.md @@ -6,7 +6,9 @@ - [ ] Version tag is created from `main` - [ ] Tag is pushed to GitHub - [ ] GitHub Release is created from the tag +- [ ] Release artifacts workflow completed for Python distributions and GHCR image - [ ] Release notes summarize user-visible changes - [ ] README and relevant docs pages match the shipped behavior - [ ] Compatibility notes are included if older runtime identifiers are still mentioned - [ ] Any upgrade notes or rollback notes are included +- [ ] PyPI publishing status is noted (published, intentionally skipped, or blocked on trusted publishing) diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..3baaeca --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,23 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + labels: + - "dependencies" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..f8bdccb --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,19 @@ +## What changed + +- + +## Why + +- + +## How verified + +- [ ] `python3 -m compileall foundrygate tests` +- [ ] `pytest -q` +- [ ] `ruff check .` +- [ ] `ruff format --check .` +- [ ] relevant docs updated + +## Risk / follow-up + +- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c862377..e85954e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,18 @@ jobs: - name: Test run: pytest tests/ -v + package: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Build Python package + run: | + python -m pip install --upgrade pip build + python -m build + lint: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..f47fbfb --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,28 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: "23 4 * * 1" + +jobs: + analyze: + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: ["python"] + steps: + - uses: actions/checkout@v4 + - uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + - uses: github/codeql-action/autobuild@v3 + - uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml new file mode 100644 index 0000000..035ede8 --- /dev/null +++ b/.github/workflows/release-artifacts.yml @@ -0,0 +1,71 @@ +name: Release Artifacts + +on: + push: + tags: + - "v*" + +jobs: + python-dist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Build sdist and wheel + run: | + python -m pip install --upgrade pip build + python -m build + - name: Upload Python artifacts + uses: actions/upload-artifact@v4 + with: + name: python-dist + path: dist/* + + ghcr: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + pypi: + if: ${{ vars.PYPI_PUBLISH == 'true' }} + runs-on: ubuntu-latest + permissions: + id-token: write + environment: + name: pypi + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Build sdist and wheel + run: | + python -m pip install --upgrade pip build + python -m build + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index 4b00bb4..2101264 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ __pycache__/ *.pyo *.pyd *.egg-info/ +build/ +dist/ # venv venv/ diff --git a/AGENTS.md b/AGENTS.md index a2780d2..dbbf1e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,7 @@ After every 4 or 5 merged PRs, do a full review pass that includes: - functional test review against real workflows where possible - documentation review and update across every relevant Markdown file - roadmap and process review if the project direction changed +- community-health and security baseline review (`CODE_OF_CONDUCT.md`, `SECURITY.md`, issue templates, PR template, Dependabot, CodeQL) Follow the branch workflow defined in: diff --git a/CHANGELOG.md b/CHANGELOG.md index 185b946..fed2e84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format is intentionally lightweight and human-readable. Group entries by rel ## Unreleased +### Added + +- Added `contract: image-provider` plus an OpenAI-compatible `POST /v1/images/generations` path for image-capable providers +- Added a shipped Dockerfile and tag-driven release-artifacts workflow for Python distributions, GHCR images, and optional PyPI publishing +- Added public community-health and security baseline files: Code of Conduct, Security Policy, issue templates, PR template, Dependabot, and CodeQL + ## v0.4.0 - 2026-03-12 ### Changed diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..467c727 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,60 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as contributors and maintainers pledge to make participation in the FoundryGate +community a harassment-free experience for everyone, regardless of age, body size, +visible or invisible disability, ethnicity, sex characteristics, gender identity and +expression, level of experience, education, socio-economic status, nationality, +personal appearance, race, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Taking responsibility and apologizing to those affected by our mistakes +- Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable +behavior and will take appropriate and fair corrective action in response to any behavior +that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, +commits, code, wiki edits, issues, and other contributions that are not aligned to this +Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an +individual is officially representing the community in public spaces. Examples of +representing our community include using an official project email address, posting via an +official social media account, or acting as an appointed representative at an online or +offline event. + +## Enforcement + +Report unacceptable behavior through the repository maintainers via GitHub. For security +issues, use the process in [SECURITY.md](./SECURITY.md) instead of opening a public report. + +All complaints will be reviewed and investigated promptly and fairly. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), +version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1810b14..9fae774 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,11 +59,23 @@ Important: Never score the system prompt for keywords. See ClawRouter's insight 4. Ensure `pytest` and `ruff check` pass 5. Open a PR with a clear description +Use the repository templates when possible: + +- bug reports via `.github/ISSUE_TEMPLATE/bug_report.yml` +- feature requests via `.github/ISSUE_TEMPLATE/feature_request.yml` +- PR descriptions via `.github/pull_request_template.md` + After every 4 or 5 merged PRs, do a broader review pass: - review unit, integration, and functional test coverage - update every relevant doc, not only the README - refresh roadmap/process docs if the project direction or workflow changed +- verify community-health and security docs still match the repo setup + +## Community And Security + +- follow [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) in all repo interactions +- report vulnerabilities through [SECURITY.md](./SECURITY.md), not a public issue See [docs/process/git-workflow.md](./docs/process/git-workflow.md) for the full branch model. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2ba9f54 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + FOUNDRYGATE_DB_PATH=/var/lib/foundrygate/foundrygate.db + +WORKDIR /app + +RUN addgroup --system foundrygate \ + && adduser --system --ingroup foundrygate --home /app foundrygate \ + && mkdir -p /var/lib/foundrygate \ + && chown -R foundrygate:foundrygate /app /var/lib/foundrygate + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . +RUN pip install --no-cache-dir . + +USER foundrygate + +EXPOSE 8090 + +CMD ["python", "-m", "foundrygate"] diff --git a/README.md b/README.md index 7f0082a..5f453d3 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,13 @@ [![repo-safety](https://github.com/typelicious/FoundryGate/actions/workflows/repo-safety.yml/badge.svg)](https://github.com/typelicious/FoundryGate/actions/workflows/repo-safety.yml) [![CI](https://github.com/typelicious/FoundryGate/actions/workflows/ci.yml/badge.svg)](https://github.com/typelicious/FoundryGate/actions/workflows/ci.yml) +[![CodeQL](https://github.com/typelicious/FoundryGate/actions/workflows/codeql.yml/badge.svg)](https://github.com/typelicious/FoundryGate/actions/workflows/codeql.yml) +[![Release](https://img.shields.io/github/v/release/typelicious/FoundryGate?display_name=tag)](https://github.com/typelicious/FoundryGate/releases) [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](./LICENSE) [![OpenAI-compatible](https://img.shields.io/badge/OpenAI-compatible-0ea5e9.svg)](./README.md#api) [![OpenClaw-friendly](https://img.shields.io/badge/OpenClaw-friendly-111827.svg)](https://openclaw.ai/) +[![Docker](https://img.shields.io/badge/docker-ready-2496ED?logo=docker&logoColor=white)](./Dockerfile) +[![PyPI](https://img.shields.io/badge/pypi-workflow%20ready-3775A9?logo=pypi&logoColor=white)](./RELEASES.md) [![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](./pyproject.toml) ## Quick Navigation @@ -20,6 +24,7 @@ - [Configuration](#configuration) - [Deployment](#deployment) - [Helper Scripts](#helper-scripts) +- [Community And Security](#community-and-security) - [Repo Safety And CI](#repo-safety-and-ci) - [Workflow](#workflow) - [Roadmap](#roadmap) @@ -30,12 +35,10 @@ Local OpenAI-compatible AI gateway for 🦞 [OpenClaw](https://openclaw.ai/) and FoundryGate is a local OpenAI-compatible router/proxy for OpenClaw and other clients. Point your client at a single local endpoint, and FoundryGate routes each request to the configured upstream provider and model, applies fallbacks on failures, and exposes health and usage data for operations. -OpenClaw site: [https://openclaw.ai/](https://openclaw.ai/) -OpenClaw docs: [https://docs.openclaw.ai/](https://docs.openclaw.ai/) - ## Why FoundryGate - OpenAI-compatible API: expose `/v1/models` and `/v1/chat/completions` to OpenClaw or any OpenAI-style client. +- Modality growth path: the runtime now includes an OpenAI-compatible `POST /v1/images/generations` path for providers marked as image-capable. - Single endpoint, multiple providers: clients call one local base URL while FoundryGate chooses the upstream provider. - Multi-provider routing: use `auto` for routing or target a provider directly by model id. - Multi-dimensional routing: score providers across locality, context headroom, token limits, cache metadata, latency, and recent failure state during provider selection. @@ -186,6 +189,23 @@ curl -fsS http://127.0.0.1:8090/v1/chat/completions \ }' ``` +### `POST /v1/images/generations` + +OpenAI-compatible image generation endpoint. + +- `model: "auto"` selects the best loaded provider with `capabilities.image_generation: true` +- `model: ""` routes directly to a loaded image-capable provider + +```bash +curl -fsS http://127.0.0.1:8090/v1/images/generations \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "auto", + "prompt": "An architectural diagram of a local AI gateway, blueprint style", + "size": "1024x1024" + }' +``` + ### Additional Stable Operational Endpoints - `POST /api/route` @@ -426,7 +446,7 @@ Each provider can expose normalized capability metadata under `capabilities:` in Supported keys today: -- Boolean flags: `chat`, `reasoning`, `vision`, `tools`, `long_context`, `streaming`, `local`, `cloud` +- Boolean flags: `chat`, `reasoning`, `vision`, `image_generation`, `image_editing`, `tools`, `long_context`, `streaming`, `local`, `cloud` - String labels: `cost_tier`, `latency_tier`, `network_zone`, `compliance_scope` What the current runtime does with them: @@ -480,6 +500,31 @@ providers: latency_tier: low ``` +### Image Provider Contract + +FoundryGate also supports `contract: image-provider` for OpenAI-compatible backends that expose `POST /images/generations`. + +What the current runtime guarantees for `image-provider`: + +- backend must be `openai-compat` +- `capabilities.image_generation` is normalized to `true` +- explicit `image_editing: true` can be declared for future editing support +- `model: "auto"` on `POST /v1/images/generations` selects only providers with image-generation capability + +Example: + +```yaml +providers: + openai-images: + contract: image-provider + backend: openai-compat + base_url: "https://api.openai.com/v1" + api_key: "${OPENAI_API_KEY}" + model: "gpt-image-1" + capabilities: + image_editing: true +``` + ### Routing Policy Schema The optional `routing_policies` block is validated at startup. FoundryGate rejects unknown provider references, unknown capability names, and unsupported select keys before the service comes up. @@ -520,7 +565,7 @@ Disable a provider: ## Deployment -FoundryGate runs fine as a plain Python process. `systemd` and helper scripts are optional conveniences. Docker can be used for quick evaluation even though the repo does not currently ship a Dockerfile. +FoundryGate runs fine as a plain Python process. `systemd` and helper scripts are optional conveniences. The repo now also ships a Dockerfile and a release-artifacts workflow for container and Python package distribution. ### Generic Linux Host @@ -559,24 +604,42 @@ sudo systemctl enable --now foundrygate.service sudo systemctl status foundrygate.service --no-pager -l ``` -### Docker (quick example, no Dockerfile required) +### Docker -This repo does not currently ship a Dockerfile. For a quick evaluation run, you can use the official Python image and mount the repo read-only: +The repo now ships [Dockerfile](./Dockerfile). A minimal local run looks like this: ```bash -docker volume create foundrygate-data +docker build -t foundrygate:dev . docker run --rm -p 8090:8090 \ --env-file .env \ - -e FOUNDRYGATE_DB_PATH=/data/foundrygate.db \ - -e PYTHONDONTWRITEBYTECODE=1 \ - -v "$PWD":/app:ro \ - -v foundrygate-data:/data \ - -w /app \ - python:3.13-slim \ - sh -lc 'pip install --no-cache-dir -r requirements.txt && python -m uvicorn foundrygate.main:app --host 0.0.0.0 --port 8090' + -e FOUNDRYGATE_DB_PATH=/var/lib/foundrygate/foundrygate.db \ + foundrygate:dev ``` -This is meant for quick evaluation. For longer-lived deployments, build your own image around the same commands. +For persistent container state, mount `/var/lib/foundrygate`: + +```bash +docker run --rm -p 8090:8090 \ + --env-file .env \ + -e FOUNDRYGATE_DB_PATH=/var/lib/foundrygate/foundrygate.db \ + -v foundrygate-data:/var/lib/foundrygate \ + foundrygate:dev +``` + +The release workflow also includes a GHCR publishing path for tagged releases. + +### Python Package / PyPI Baseline + +The repo now ships a release workflow that builds wheels and source distributions on version tags. + +Local package build: + +```bash +python -m pip install --upgrade build +python -m build +``` + +Tagged releases always build Python artifacts. PyPI publishing is wired behind the repository variable `PYPI_PUBLISH=true` plus GitHub trusted publishing for the `pypi` environment. ## Helper Scripts @@ -598,12 +661,31 @@ Running `./scripts/foundrygate-install` also creates symlinks in `/usr/local/bin `foundrygate-stats --json` now also includes client/profile breakdowns alongside provider and routing summaries. +## Community And Security + +FoundryGate now includes the core public community-health files expected for a public open-source repo: + +- [Code of Conduct](./CODE_OF_CONDUCT.md) +- [Contributing Guide](./CONTRIBUTING.md) +- [Security Policy](./SECURITY.md) +- [Issue Templates](./.github/ISSUE_TEMPLATE) +- [Pull Request Template](./.github/pull_request_template.md) + +Security automation and review baseline: + +- [repo-safety](./.github/workflows/repo-safety.yml) blocks tracked secrets-like artifacts and runtime files +- [CodeQL](./.github/workflows/codeql.yml) provides code scanning on `main`, pull requests, and a weekly schedule +- [Dependabot](./.github/dependabot.yml) tracks Python, GitHub Actions, and Docker dependencies +- GitHub secret scanning is already active at the repository level + ## Repo Safety And CI FoundryGate includes two GitHub Actions workflows: - [CI](./.github/workflows/ci.yml): runs Ruff plus the test matrix on Python 3.10 through 3.13 +- [release-artifacts](./.github/workflows/release-artifacts.yml): builds Python distributions on tags, pushes container images to GHCR, and can publish to PyPI when trusted publishing is configured - [repo-safety](./.github/workflows/repo-safety.yml): rejects accidental artifacts and secrets-like files +- [CodeQL](./.github/workflows/codeql.yml): performs repository code scanning for Python The `repo-safety` workflow fails pull requests if these patterns are tracked in the working tree or still exist anywhere in Git history: diff --git a/RELEASES.md b/RELEASES.md index 61b8105..1c9adb4 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -1,6 +1,6 @@ # FoundryGate Releases -This repo does not require a heavy release process. Use lightweight tags plus GitHub Releases. +This repo does not require a heavy release process. Use lightweight tags plus GitHub Releases, with release automation building the Python package and container image from tags. ## Release Flow @@ -10,9 +10,10 @@ This repo does not require a heavy release process. Use lightweight tags plus Gi - Keep the notes focused on user-visible changes. 3. Create an annotated tag from `main`. 4. Push the tag to GitHub. -5. Create a GitHub Release from that tag. -6. Use the changelog entry as the release notes, then add any short upgrade notes if needed. -7. Confirm that README plus the relevant docs pages still match the shipped runtime behavior. +5. Let the release-artifacts workflow build Python distributions and the GHCR image. +6. Create a GitHub Release from that tag. +7. Use the changelog entry as the release notes, then add any short upgrade notes if needed. +8. Confirm that README plus the relevant docs pages still match the shipped runtime behavior. ## Example @@ -25,6 +26,14 @@ git push origin v0.4.0 Then open GitHub Releases and publish a release for `v0.4.0`. +## Automation Baseline + +Tagged releases now trigger [release-artifacts](./.github/workflows/release-artifacts.yml): + +- always build `sdist` and `wheel` +- push the container image to GHCR +- publish to PyPI only when `PYPI_PUBLISH=true` is set and GitHub trusted publishing is configured for the `pypi` environment + ## Versioning Guidance - Use `x.y.z` version numbers and matching `vx.y.z` Git tags. @@ -41,7 +50,7 @@ Then open GitHub Releases and publish a release for `v0.4.0`. ## Planned Publishing Path - `v0.3.x`: GitHub Releases plus source checkout remain the default distribution path. -- `v0.5.0`: add Docker and PyPI as supported release channels. +- `v0.5.0`: Docker and PyPI publishing baseline is introduced through the release workflow and repo docs. - `v1.0.0`: keep GitHub Releases, Docker, and PyPI, and add a separate npm or TypeScript CLI package if the CLI surface is ready. The npm or TypeScript package should stay separate from the Python gateway core. It is meant for CLI-facing integrations, not for rewriting the service runtime. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..a313c7b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,47 @@ +# Security Policy + +## Supported Versions + +FoundryGate is currently maintained on the latest `main` branch and the most recent tagged release line. + +| Version | Supported | +| --- | --- | +| `main` | Yes | +| Latest tagged release | Yes | +| Older releases | Best effort only | + +## Reporting a Vulnerability + +Do not open a public issue for a suspected vulnerability. + +Preferred path: + +1. Use GitHub private vulnerability reporting for this repository when available. +2. If private reporting is not available in your GitHub session, open a private GitHub security advisory draft for this repository. +3. Include affected version or commit, reproduction steps, impact, and any suggested mitigation. + +Expected handling: + +- initial acknowledgement target: within 5 business days +- status update target: within 10 business days after acknowledgement +- coordinated disclosure after a fix or documented mitigation is ready + +## Scope + +Please report issues such as: + +- request, header, or parameter injection +- dashboard XSS or HTML/CSS injection +- unsafe file-path handling or writable-path assumptions +- auth or secret-handling mistakes +- dependency vulnerabilities with practical impact +- trust-boundary issues between FoundryGate and upstream or local providers + +## Operational Guidance + +To reduce risk in deployments: + +- keep `FOUNDRYGATE_DB_PATH` outside the repo checkout +- avoid committing `.env`, database files, SQLite files, logs, or SSH material +- run with the provided `systemd` hardening or an equivalent container/runtime policy +- keep provider API keys scoped to the minimum set of enabled providers diff --git a/config.yaml b/config.yaml index 683b95f..266ca0d 100644 --- a/config.yaml +++ b/config.yaml @@ -180,6 +180,16 @@ providers: # output: 10.00 # cache_read: 1.25 + #openai-images: + # contract: image-provider + # backend: openai-compat + # base_url: "${OPENAI_BASE_URL:-https://api.openai.com/v1}" + # api_key: "${OPENAI_API_KEY}" + # model: "gpt-image-1" + # tier: default + # capabilities: + # image_editing: true + # ── Anthropic ─────────────────────────────────────────────────────────── # Auth: ANTHROPIC_API_KEY (or setup-token) # Rotation: ANTHROPIC_API_KEYS, OPENCLAW_LIVE_ANTHROPIC_KEY diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1693b5a..6ef50fa 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -7,6 +7,7 @@ FoundryGate is a local-first AI gateway plane. Its job is to sit between many clients and many model backends while keeping one stable operational surface: - one OpenAI-compatible endpoint +- one gateway surface for chat and image-generation requests - many providers - explicit routing and fallback behavior - observable health and usage data @@ -50,6 +51,7 @@ The provider layer already supports: - OpenAI-compatible backends - Google GenAI backends - `contract: local-worker` for LAN/local OpenAI-compatible workers +- `contract: image-provider` for OpenAI-compatible image generation backends Each provider can expose: @@ -91,6 +93,7 @@ The main operational endpoints are: - `GET /health` - `GET /v1/models` - `POST /v1/chat/completions` +- `POST /v1/images/generations` - `POST /api/route` - `GET /api/stats` - `GET /api/recent` diff --git a/docs/FOUNDRYGATE-ROADMAP.md b/docs/FOUNDRYGATE-ROADMAP.md index 8771f34..91b5017 100644 --- a/docs/FOUNDRYGATE-ROADMAP.md +++ b/docs/FOUNDRYGATE-ROADMAP.md @@ -178,10 +178,12 @@ This release line should deepen the gateway core without turning it into a monol Primary goals: +- add the first modality-aware provider contract, starting with image generation - publish an official Docker release path - publish FoundryGate to PyPI - add provider and client onboarding helpers for many-provider and many-client deployments - add validation workflows so operators can catch config mistakes before rollout +- complete the public community-health baseline and security-overview baseline for the repo This is the first release line where installation and upgrade paths should feel productized for external users. diff --git a/docs/INTEGRATIONS.md b/docs/INTEGRATIONS.md index 2a5d5ad..86053ed 100644 --- a/docs/INTEGRATIONS.md +++ b/docs/INTEGRATIONS.md @@ -83,9 +83,9 @@ When onboarding a new client: ## Planned integration directions -These are roadmap items, not current runtime features: +These are roadmap items or early foundations: -- image generation routing +- image generation routing through `POST /v1/images/generations` for providers that declare `contract: image-provider` - optional request hooks for context or optimization - richer CLI-sidecar adapters - provider and client onboarding helpers diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index 9a4dbab..296369c 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -26,6 +26,7 @@ The safest onboarding order is: - check `GET /health` - check `GET /v1/models` - for `contract: local-worker`, confirm that `GET /models` works on the worker +- for `contract: image-provider`, confirm that the upstream exposes `POST /images/generations` ### 3. Validate routing @@ -91,6 +92,7 @@ Recommended rollout: Current state: - manual updates via Git or `foundrygate-update` +- tag-driven release artifacts for Python distributions and container images Planned state: diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index faeea69..7aacc25 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -83,6 +83,26 @@ If the worker is healthy but still loses route selection, inspect `POST /api/rou - `locality_score` - `latency_score` +## Image generation fails + +Check whether any loaded provider actually exposes image-generation capability: + +```bash +curl -fsS http://127.0.0.1:8090/v1/models +curl -fsS http://127.0.0.1:8090/health +``` + +For `contract: image-provider`, validate the upstream directly: + +```bash +curl -fsS https://api.example.com/v1/images/generations \ + -H 'Authorization: Bearer YOUR_KEY' \ + -H 'Content-Type: application/json' \ + -d '{"model":"gpt-image-1","prompt":"test"}' +``` + +If `model: "auto"` still fails, verify that at least one loaded provider reports `capabilities.image_generation: true`. + ## Many-agent OpenClaw traffic is not separated Check whether `x-openclaw-source` is present. diff --git a/docs/process/git-workflow.md b/docs/process/git-workflow.md index 438c270..ace1e1f 100644 --- a/docs/process/git-workflow.md +++ b/docs/process/git-workflow.md @@ -54,6 +54,7 @@ That pass should include: - integration test review - functional test review against current user workflows - documentation review across README, roadmap, process docs, troubleshooting docs, and integration guides +- community-health and security review across code of conduct, security policy, issue templates, PR template, and GitHub security automation - cleanup of stale assumptions, outdated examples, or renamed surfaces This keeps the project understandable from the outside and prevents documentation drift after several fast feature PRs. diff --git a/foundrygate/__init__.py b/foundrygate/__init__.py index 08e7f06..959bfb4 100644 --- a/foundrygate/__init__.py +++ b/foundrygate/__init__.py @@ -1,3 +1,3 @@ """FoundryGate package.""" -__version__ = "0.3.0" +__version__ = "0.4.0" diff --git a/foundrygate/config.py b/foundrygate/config.py index d4daef3..74ce001 100644 --- a/foundrygate/config.py +++ b/foundrygate/config.py @@ -31,12 +31,14 @@ from .hooks import get_registered_request_hooks _SUPPORTED_BACKENDS = {"openai-compat", "google-genai", "anthropic-compat"} -_SUPPORTED_PROVIDER_CONTRACTS = {"generic", "local-worker"} +_SUPPORTED_PROVIDER_CONTRACTS = {"generic", "local-worker", "image-provider"} _SUPPORTED_CACHE_MODES = {"none", "implicit", "explicit"} _BOOL_CAPABILITY_FIELDS = { "chat", "reasoning", "vision", + "image_generation", + "image_editing", "tools", "long_context", "streaming", @@ -197,6 +199,8 @@ def _normalize_provider_capabilities(name: str, cfg: dict[str, Any]) -> dict[str "chat": True, "reasoning": tier == "reasoning" or "reasoner" in model, "vision": False, + "image_generation": False, + "image_editing": False, "tools": False, "long_context": context_window >= 128_000 or max_input_tokens >= 128_000, "streaming": backend != "google-genai", @@ -361,6 +365,20 @@ def _normalize_provider(name: str, cfg: Any) -> dict[str, Any]: "cloud": False, "network_zone": "local", } + elif contract == "image-provider": + if backend != "openai-compat": + raise ConfigError( + f"Provider '{name}' contract 'image-provider' requires backend 'openai-compat'" + ) + raw_capabilities = normalized.get("capabilities") + if raw_capabilities is None: + raw_capabilities = {} + if not isinstance(raw_capabilities, dict): + raise ConfigError(f"Provider '{name}' capabilities must be a mapping") + normalized["capabilities"] = { + **raw_capabilities, + "image_generation": True, + } normalized["limits"] = _normalize_provider_limits(name, normalized) if "max_tokens" in normalized and "max_output_tokens" not in normalized["limits"]: diff --git a/foundrygate/main.py b/foundrygate/main.py index 4ae99ac..b0cd64a 100644 --- a/foundrygate/main.py +++ b/foundrygate/main.py @@ -116,12 +116,22 @@ def _resolve_client_tag(headers: dict[str, str], client_profile: str) -> str: return client_profile -def _build_attempt_order(primary_provider: str) -> list[str]: +def _build_attempt_order( + primary_provider: str, + *, + required_capabilities: list[str] | None = None, +) -> list[str]: """Return the provider attempt order for one routed request.""" attempt_order = [] for provider_name in [primary_provider, *_config.fallback_chain]: - if provider_name in _providers and provider_name not in attempt_order: - attempt_order.append(provider_name) + provider = _providers.get(provider_name) + if not provider or provider_name in attempt_order: + continue + if required_capabilities and any( + not provider.capabilities.get(capability) for capability in required_capabilities + ): + continue + attempt_order.append(provider_name) return attempt_order @@ -258,6 +268,78 @@ async def _resolve_route_preview( ) +def _collect_image_request_fields(body: dict[str, Any]) -> dict[str, Any]: + """Return a narrow, validated subset of image-generation request fields.""" + fields: dict[str, Any] = {} + if isinstance(body.get("n"), int) and body["n"] > 0: + fields["n"] = body["n"] + for key in ("size", "quality", "response_format", "style", "background", "user"): + value = body.get(key) + if isinstance(value, str) and value.strip(): + fields[key] = value.strip() + return fields + + +async def _resolve_image_route_preview( + body: dict[str, Any], headers: dict[str, str] +) -> tuple[RoutingDecision, str, str, list[str], str, AppliedHooks, dict[str, Any]]: + """Resolve one image-generation request without calling a provider.""" + body, hook_state = await _apply_request_hooks(body, headers) + prompt = body.get("prompt") + if not isinstance(prompt, str) or not prompt.strip(): + raise ValueError("Image generation requires a non-empty 'prompt' string") + + model_requested = str(body.get("model", "auto")) + client_profile, profile_hints = _resolve_client_profile( + _config, + headers, + profile_override=hook_state.profile_override, + ) + client_tag = _resolve_client_tag(headers, client_profile) + + if model_requested != "auto": + provider = _providers.get(model_requested) + if not provider: + raise ValueError(f"Unknown image provider '{model_requested}'") + if not provider.capabilities.get("image_generation"): + raise ValueError(f"Provider '{model_requested}' does not support image generation") + decision = RoutingDecision( + provider_name=model_requested, + layer="direct", + rule_name="explicit-image-model", + confidence=1.0, + reason=f"Directly requested image provider: {model_requested}", + details={"required_capability": "image_generation"}, + ) + else: + decision = _router.route_capability_request( + capability="image_generation", + request_text=prompt, + model_requested=model_requested, + client_profile=client_profile, + profile_hints=profile_hints, + hook_hints=hook_state.routing_hints, + applied_hooks=hook_state.applied_hooks, + headers=headers, + provider_health={name: p.health.to_dict() for name, p in _providers.items()}, + ) + if not decision: + raise ValueError("No image-generation provider is available") + + return ( + decision, + client_profile, + client_tag, + _build_attempt_order( + decision.provider_name, + required_capabilities=["image_generation"], + ), + model_requested, + hook_state, + body, + ) + + @asynccontextmanager async def lifespan(app: FastAPI): """Startup / shutdown lifecycle.""" @@ -476,6 +558,104 @@ async def preview_route(request: Request): } +@app.post("/v1/images/generations") +async def image_generations(request: Request): + """OpenAI-compatible image generation endpoint.""" + try: + body = await request.json() + except Exception: + return JSONResponse({"error": "Invalid JSON body"}, status_code=400) + + headers = _collect_routing_headers(request) + try: + ( + decision, + client_profile, + client_tag, + attempt_order, + model_requested, + hook_state, + effective_body, + ) = await _resolve_image_route_preview(body, headers) + except HookExecutionError as exc: + return JSONResponse({"error": str(exc), "type": "request_hook_error"}, status_code=500) + except ValueError as exc: + return JSONResponse({"error": str(exc), "type": "invalid_request_error"}, status_code=400) + + prompt = effective_body["prompt"].strip() + image_fields = _collect_image_request_fields(effective_body) + errors: list[str] = [] + + for provider_name in attempt_order: + provider = _providers.get(provider_name) + if not provider: + continue + if not provider.health.healthy and provider_name != attempt_order[0]: + continue + + try: + result = await provider.generate_image( + prompt, + extra_body=image_fields, + ) + if _config.metrics.get("enabled") and isinstance(result, dict): + _metrics.log_request( + provider=provider_name, + model=provider.model, + layer=decision.layer, + rule_name=decision.rule_name, + latency_ms=(result.get("_foundrygate") or {}).get("latency_ms", 0), + requested_model=model_requested, + client_profile=client_profile, + client_tag=client_tag, + decision_reason=decision.reason, + confidence=decision.confidence, + attempt_order=attempt_order, + ) + + resp = JSONResponse(result) + resp.headers["X-FoundryGate-Provider"] = provider_name + resp.headers["X-FoundryGate-Profile"] = client_profile + resp.headers["X-FoundryGate-Layer"] = decision.layer + resp.headers["X-FoundryGate-Rule"] = decision.rule_name + resp.headers["X-FoundryGate-Hooks"] = ",".join(hook_state.applied_hooks) + resp.headers["X-FoundryGate-Hook-Errors"] = str(len(hook_state.errors)) + return resp + except ProviderError as exc: + errors.append(f"{provider_name}: {exc.detail}") + logger.warning( + "Image provider %s failed: %s, trying next...", + provider_name, + exc.detail[:200], + ) + if _config.metrics.get("enabled"): + _metrics.log_request( + provider=provider_name, + model=provider.model, + layer=decision.layer, + rule_name=decision.rule_name, + success=False, + error=exc.detail[:500], + requested_model=model_requested, + client_profile=client_profile, + client_tag=client_tag, + decision_reason=decision.reason, + confidence=decision.confidence, + attempt_order=attempt_order, + ) + + return JSONResponse( + { + "error": { + "message": f"All image providers failed: {'; '.join(errors)}", + "type": "provider_error", + "attempts": errors, + } + }, + status_code=502, + ) + + @app.get("/dashboard", response_class=HTMLResponse) async def dashboard(): """Minimal self-contained dashboard – no build step, no deps.""" diff --git a/foundrygate/providers.py b/foundrygate/providers.py index b8e51c7..d84b04e 100644 --- a/foundrygate/providers.py +++ b/foundrygate/providers.py @@ -116,6 +116,86 @@ async def probe_health(self, timeout_seconds: float = 10.0) -> bool: self.health.record_failure(f"Probe connection error: {e}") return False + async def generate_image( + self, + prompt: str, + *, + model_override: str | None = None, + n: int = 1, + size: str | None = None, + quality: str | None = None, + response_format: str | None = None, + style: str | None = None, + background: str | None = None, + user: str | None = None, + extra_body: dict[str, Any] | None = None, + ) -> dict: + """Send an OpenAI-compatible image generation request.""" + if self.backend_type != "openai-compat": + raise ProviderError( + self.name, + 0, + f"Image generation is not implemented for backend '{self.backend_type}'", + ) + + model = model_override or self.model + body: dict[str, Any] = { + "model": model, + "prompt": prompt, + "n": n, + } + if size: + body["size"] = size + if quality: + body["quality"] = quality + if response_format: + body["response_format"] = response_format + if style: + body["style"] = style + if background: + body["background"] = background + if user: + body["user"] = user + if extra_body: + body.update(extra_body) + + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + if "openrouter" in self.base_url: + headers["HTTP-Referer"] = "https://foundrygate.local" + headers["X-Title"] = "FoundryGate" + + url = f"{self.base_url}/images/generations" + t0 = time.time() + + try: + resp = await self._client.post(url, json=body, headers=headers) + latency = (time.time() - t0) * 1000 + + if resp.status_code >= 400: + error_text = resp.text[:500] + self.health.record_failure(f"HTTP {resp.status_code}: {error_text}") + raise ProviderError(self.name, resp.status_code, error_text) + + self.health.record_success(latency) + data = resp.json() + data["_foundrygate"] = { + "provider": self.name, + "model": model, + "latency_ms": round(latency, 1), + "modality": "image_generation", + } + return data + + except httpx.TimeoutException as e: + self.health.record_failure(f"Timeout: {e}") + raise ProviderError(self.name, 0, f"Timeout: {e}") from e + except httpx.ConnectError as e: + self.health.record_failure(f"Connection error: {e}") + raise ProviderError(self.name, 0, f"Connection error: {e}") from e + # ── OpenAI-compatible completion ─────────────────────────── async def complete( diff --git a/foundrygate/router.py b/foundrygate/router.py index 8bc53e7..6763e45 100644 --- a/foundrygate/router.py +++ b/foundrygate/router.py @@ -58,6 +58,50 @@ def _score_capacity_ratio(ratio: float, *, strong: float = 2.0, ideal: float = 4 return 10 +def _merge_select_constraints(*selects: dict[str, Any]) -> dict[str, Any]: + """Merge policy-like select mappings without dropping list/dict constraints.""" + merged: dict[str, Any] = { + "allow_providers": [], + "deny_providers": [], + "prefer_providers": [], + "prefer_tiers": [], + "require_capabilities": [], + "capability_values": {}, + } + + for select in selects: + if not select: + continue + + for key in ( + "allow_providers", + "deny_providers", + "prefer_providers", + "prefer_tiers", + "require_capabilities", + ): + values = select.get(key, []) + if isinstance(values, str): + values = [values] + elif not isinstance(values, list): + continue + for value in values: + if value not in merged[key]: + merged[key].append(value) + + raw_capability_values = select.get("capability_values", {}) + if not isinstance(raw_capability_values, dict): + continue + for capability, values in raw_capability_values.items(): + normalized_values = values if isinstance(values, list) else [values] + merged["capability_values"].setdefault(capability, []) + for value in normalized_values: + if value not in merged["capability_values"][capability]: + merged["capability_values"][capability].append(value) + + return merged + + def _extract_text(messages: list[dict]) -> tuple[str, str, str]: """Extract system prompt, last user message, and full conversation text. @@ -183,6 +227,96 @@ async def route( elapsed_ms=elapsed, ) + def route_capability_request( + self, + *, + capability: str, + request_text: str = "", + model_requested: str = "", + client_profile: str = "generic", + profile_hints: dict[str, Any] | None = None, + hook_hints: dict[str, Any] | None = None, + applied_hooks: list[str] | None = None, + headers: dict[str, str] | None = None, + provider_health: dict[str, Any] | None = None, + candidate_names: list[str] | None = None, + ) -> RoutingDecision | None: + """Route one non-chat request against providers with a required capability.""" + t0 = time.time() + total_tokens = _estimate_tokens(request_text) if request_text else 0 + ctx = _RoutingContext( + system_prompt="", + last_user_message=request_text, + full_text=request_text, + total_tokens=total_tokens, + stable_prefix_tokens=0, + requested_output_tokens=0, + total_requested_tokens=total_tokens, + cache_preference=(headers or {}).get("x-foundrygate-cache", "").strip().lower(), + model_requested=model_requested.lower().strip(), + has_tools=False, + client_profile=client_profile, + profile_hints=profile_hints or {}, + hook_hints=hook_hints or {}, + applied_hooks=applied_hooks or [], + headers=headers or {}, + provider_health=provider_health or {}, + providers=self.config.providers, + ) + + base_select = _merge_select_constraints( + { + "require_capabilities": [capability], + "allow_providers": candidate_names or [], + } + ) + + policy_decision = self._layer_capability_policy(ctx, capability, base_select) + if policy_decision: + policy_decision.elapsed_ms = (time.time() - t0) * 1000 + return self._validate_health( + policy_decision, + ctx, + required_capabilities=[capability], + ) + + for layer_name, selector, confidence, reason in ( + ("hook", ctx.hook_hints, 0.7, "Request hooks selected a preferred provider"), + ( + "profile", + ctx.profile_hints, + 0.6, + f"Client profile '{ctx.client_profile}' selected a preferred provider", + ), + ( + "capability-default", + {}, + 0.5, + f"Selected the best available provider with capability '{capability}'", + ), + ): + provider_name, ranking = self._select_policy_provider( + _merge_select_constraints(base_select, selector), + ctx, + ) + if not provider_name: + continue + decision = RoutingDecision( + provider_name=provider_name, + layer=layer_name, + rule_name=f"{capability}-{layer_name}", + confidence=confidence, + reason=reason, + details={ + "required_capability": capability, + "candidate_ranking": ranking, + }, + ) + decision.elapsed_ms = (time.time() - t0) * 1000 + return self._validate_health(decision, ctx, required_capabilities=[capability]) + + return None + # ── Layer 0: Policy Rules ────────────────────────────────── def _layer_policy(self, ctx: _RoutingContext) -> RoutingDecision | None: @@ -212,6 +346,44 @@ def _layer_policy(self, ctx: _RoutingContext) -> RoutingDecision | None: return None + def _layer_capability_policy( + self, + ctx: _RoutingContext, + capability: str, + base_select: dict[str, Any], + ) -> RoutingDecision | None: + """Apply routing policies while enforcing one required capability.""" + cfg = self.config.routing_policies + if not cfg.get("enabled"): + return None + + for rule in cfg.get("rules", []): + if not self._match_policy(rule.get("match", {}), ctx): + continue + + provider_name, ranking = self._select_policy_provider( + _merge_select_constraints(base_select, rule.get("select", {})), + ctx, + ) + if not provider_name: + continue + + return RoutingDecision( + provider_name=provider_name, + layer="policy", + rule_name=rule["name"], + confidence=0.95, + reason=( + f"Policy rule '{rule['name']}' matched for required capability '{capability}'" + ), + details={ + "required_capability": capability, + "candidate_ranking": ranking, + }, + ) + + return None + def _match_policy(self, match: dict, ctx: _RoutingContext) -> bool: """Evaluate a policy match block using the existing static/heuristic primitives.""" if not match: @@ -672,7 +844,13 @@ async def _layer_llm_classify(self, ctx: _RoutingContext) -> RoutingDecision | N # ── Health validation ────────────────────────────────────── - def _validate_health(self, decision: RoutingDecision, ctx: _RoutingContext) -> RoutingDecision: + def _validate_health( + self, + decision: RoutingDecision, + ctx: _RoutingContext, + *, + required_capabilities: list[str] | None = None, + ) -> RoutingDecision: """If chosen provider is unhealthy or over limits, fall through the chain.""" health = ctx.provider_health.get(decision.provider_name) primary = self.config.provider(decision.provider_name) or {} @@ -694,6 +872,11 @@ def _validate_health(self, decision: RoutingDecision, ctx: _RoutingContext) -> R fb_health = ctx.provider_health.get(fallback, {}) if not fb_health.get("healthy", True): continue + if required_capabilities and any( + not provider.get("capabilities", {}).get(capability) + for capability in required_capabilities + ): + continue if not self._provider_fits_request_dimensions(fallback, provider, ctx): continue fallback_candidates.append(fallback) diff --git a/openclaw-integration.jsonc b/openclaw-integration.jsonc index 084680e..950b48e 100644 --- a/openclaw-integration.jsonc +++ b/openclaw-integration.jsonc @@ -73,7 +73,7 @@ "foundrygate/openrouter-fallback": { "alias": "or" } }, "imageModel": { - "primary": "foundrygate/gemini-flash-lite", + "primary": "foundrygate/auto", "fallbacks": [] }, "heartbeat": { diff --git a/pyproject.toml b/pyproject.toml index 42a76a5..fbf3e87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,13 +7,12 @@ name = "foundrygate" version = "0.4.0" description = "Local OpenAI-compatible routing gateway for OpenClaw and other AI-native clients." readme = "README.md" -license = {text = "Apache-2.0"} +license = "Apache-2.0" requires-python = ">=3.10" keywords = ["foundrygate", "openclaw", "llm", "router", "gateway", "proxy", "multi-provider"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -32,6 +31,7 @@ dependencies = [ [project.optional-dependencies] dev = [ + "build>=1.2", "pytest>=8.0", "pytest-asyncio>=0.24", "httpx", # for TestClient diff --git a/skills/foundrygate/SKILL.md b/skills/foundrygate/SKILL.md index 477e91c..5813911 100644 --- a/skills/foundrygate/SKILL.md +++ b/skills/foundrygate/SKILL.md @@ -6,7 +6,7 @@ metadata: {"openclaw":{"requires":{"bins":["curl"]},"emoji":"🚪","homepage":"h # FoundryGate Skill -FoundryGate is a local routing proxy that sits between OpenClaw and your LLM providers (DeepSeek, Gemini, OpenRouter). +FoundryGate is a local routing proxy that sits between OpenClaw and your model providers (chat and image-capable backends). ## Available Commands @@ -63,6 +63,19 @@ curl -s http://127.0.0.1:8090/api/route \ Show the selected provider, routing layer, rule, resolved profile, and attempt order. If relevant headers matter for routing, include them in the dry-run request. +### /foundrygate image +Dry-run one image-generation request shape by calling the image endpoint directly. + +```bash +curl -s http://127.0.0.1:8090/v1/images/generations \ + -H "Content-Type: application/json" \ + -d '{ + "model": "auto", + "prompt": "PROMPT_HERE", + "size": "1024x1024" + }' | python3 -m json.tool +``` + ### /foundrygate recent Show the last 10 requests with provider, layer, rule, tokens, cost, and status. @@ -91,7 +104,7 @@ A web dashboard is available at http://127.0.0.1:8090/dashboard — open it in a ## How Routing Works -FoundryGate uses 5 routing stages (evaluated in order, first decisive match wins): +FoundryGate uses 6 routing stages for chat requests (evaluated in order, first decisive match wins): 1. **Policy rules**: Governance, local/cloud constraints, and capability-aware provider selection 2. **Static rules**: Pattern matching on model name and headers (heartbeats, explicit model requests, subagent detection) diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index 333ed16..001c7d0 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -203,3 +203,41 @@ def test_local_worker_contract_rejects_non_openai_backend(tmp_path): with pytest.raises(ConfigError, match="requires backend 'openai-compat'"): load_config(path) + + +def test_image_provider_contract_enables_image_generation(tmp_path): + path = _write_config( + tmp_path, + ( + " image-cloud:\n" + " contract: image-provider\n" + " backend: openai-compat\n" + ' base_url: "https://api.example.com/v1"\n' + ' api_key: "secret"\n' + ' model: "gpt-image-1"\n' + ), + ) + + cfg = load_config(path) + provider = cfg.provider("image-cloud") + + assert provider["contract"] == "image-provider" + assert provider["capabilities"]["image_generation"] is True + assert provider["capabilities"]["image_editing"] is False + + +def test_image_provider_contract_rejects_non_openai_backend(tmp_path): + path = _write_config( + tmp_path, + ( + " image-cloud:\n" + " contract: image-provider\n" + " backend: google-genai\n" + ' base_url: "https://generativelanguage.googleapis.com/v1beta"\n' + ' api_key: "secret"\n' + ' model: "imagen"\n' + ), + ) + + with pytest.raises(ConfigError, match="image-provider"): + load_config(path) diff --git a/tests/test_providers.py b/tests/test_providers.py index 8027f18..0e22551 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -220,3 +220,49 @@ async def test_multimodal_content_array_flattened(self): for item in captured.get("contents", []): for part in item.get("parts", []): assert isinstance(part.get("text"), str), f"Non-string text in part: {part}" + + +class TestImageGeneration: + @pytest.mark.asyncio + async def test_openai_image_generation_posts_to_images_endpoint(self): + backend = ProviderBackend( + "image-cloud", + { + "contract": "image-provider", + "backend": "openai-compat", + "base_url": "https://api.example.com/v1", + "api_key": "secret", + "model": "gpt-image-1", + }, + ) + captured: dict = {} + + class _FakeResp: + status_code = 200 + + def json(self): + return {"created": 1, "data": [{"b64_json": "abc"}]} + + async def _fake_post(url, json=None, headers=None, **kw): + captured["url"] = url + captured["json"] = json or {} + captured["headers"] = headers or {} + return _FakeResp() + + backend._client.post = _fake_post # type: ignore[attr-defined] + + result = await backend.generate_image( + "draw a lighthouse", + size="1024x1024", + response_format="b64_json", + user="tester", + ) + + assert captured["url"] == "https://api.example.com/v1/images/generations" + assert captured["json"]["model"] == "gpt-image-1" + assert captured["json"]["prompt"] == "draw a lighthouse" + assert captured["json"]["size"] == "1024x1024" + assert captured["json"]["response_format"] == "b64_json" + assert captured["json"]["user"] == "tester" + assert result["_foundrygate"]["provider"] == "image-cloud" + assert result["_foundrygate"]["modality"] == "image_generation" diff --git a/tests/test_route_introspection.py b/tests/test_route_introspection.py index 4e9caaa..3dd6117 100644 --- a/tests/test_route_introspection.py +++ b/tests/test_route_introspection.py @@ -39,7 +39,11 @@ async def aclose(self): import foundrygate.main as main_module from foundrygate.config import load_config -from foundrygate.main import _refresh_local_worker_probes, _resolve_route_preview +from foundrygate.main import ( + _refresh_local_worker_probes, + _resolve_image_route_preview, + _resolve_route_preview, +) from foundrygate.router import Router @@ -109,6 +113,13 @@ def preview_config(tmp_path, monkeypatch): api_key: "local" model: "llama3" tier: local + image-cloud: + contract: image-provider + backend: openai-compat + base_url: "https://api.example.com/v1" + api_key: "secret" + model: "gpt-image-1" + tier: default client_profiles: enabled: true default: generic @@ -148,6 +159,18 @@ def preview_config(tmp_path, monkeypatch): tier="local", capabilities={"local": True, "cloud": False, "network_zone": "local"}, ), + "image-cloud": _ProviderStub( + name="image-cloud", + model="gpt-image-1", + contract="image-provider", + tier="default", + capabilities={ + "local": False, + "cloud": True, + "network_zone": "public", + "image_generation": True, + }, + ), }, raising=False, ) @@ -209,6 +232,34 @@ async def test_preview_direct_model_keeps_explicit_provider_first(self, preview_ assert hook_state.applied_hooks == [] assert effective_body["model"] == "cloud-default" + @pytest.mark.asyncio + async def test_image_preview_selects_image_provider(self, preview_config): + ( + decision, + profile_name, + client_tag, + attempt_order, + model_requested, + hook_state, + effective_body, + ) = await _resolve_image_route_preview( + { + "model": "auto", + "prompt": "Draw a blueprint-style gateway diagram.", + "size": "1024x1024", + }, + {}, + ) + + assert model_requested == "auto" + assert profile_name == "generic" + assert client_tag == "generic" + assert decision.provider_name == "image-cloud" + assert decision.details["required_capability"] == "image_generation" + assert attempt_order == ["image-cloud"] + assert hook_state.applied_hooks == [] + assert effective_body["prompt"] == "Draw a blueprint-style gateway diagram." + class TestLocalWorkerProbeRefresh: @pytest.mark.asyncio