Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 12 additions & 14 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ name: CI
# src, coverage that cannot silently decay, and an installable wheel.
#
# Required checks (branch protection):
# check (py3.9) · check (py3.11) · check (py3.13) · types · build · verify-port
# check (py3.10) · check (py3.11) · check (py3.13) · types · build · verify-port
# Deliberately NOT required: e2e — it exits 0 when the API key is absent (forks),
# so requiring it would be a green rubber stamp.

Expand All @@ -24,23 +24,21 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
# pyproject declares requires-python = ">=3.9.2", but CI used to test 3.11
# only, so a 3.9- or 3.13-specific break could land unnoticed. asyncio
# pyproject declares requires-python = ">=3.10", but CI used to test 3.11
# only, so a 3.10- or 3.13-specific break could land unnoticed. asyncio
# primitives are the real hazard here: asyncio.Condition() binds the running
# loop eagerly on 3.9 and lazily on 3.13.
# loop eagerly on older versions and lazily on 3.13.
check:
name: check (py${{ matrix.python-version }})
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
# Do not let a 3.9-only failure mask a 3.13-only failure.
# Do not let a 3.10-only failure mask a 3.13-only failure.
fail-fast: false
matrix:
# "3.9" resolves to 3.9.25 and satisfies ">=3.9.2". The exact patch 3.9.2
# is NOT pinnable: actions/python-versions ships no 3.9.2 build for
# ubuntu-24.04 (16.04/18.04/20.04 only), so `python-version: "3.9.2"`
# fails to install on ubuntu-latest.
python-version: ["3.9", "3.11", "3.13"]
# 3.10 is the floor because `openrouter` 1.x requires >=3.10 (the SDK
# dropped 3.9 at 1.0.0). Python 3.9 reached EOL in October 2025.
python-version: ["3.10", "3.11", "3.13"]
steps:
- uses: actions/checkout@v4

Expand Down Expand Up @@ -103,7 +101,7 @@ jobs:
# tests/ included on purpose: CI used to check src only, so every fake
# client and payload builder in tests/ was unverified — exactly where an
# Optional deref makes an assertion silently no-op. Not matrixed because
# [tool.mypy] python_version = "3.9" pins the analysis target, so the
# [tool.mypy] python_version = "3.10" pins the analysis target, so the
# output is identical on every interpreter.
- name: Type check
run: uv run mypy src tests
Expand All @@ -117,10 +115,10 @@ jobs:
- uses: actions/checkout@v4

# Built on the oldest supported interpreter so a wheel that only imports
# on newer syntax fails here rather than for a user on 3.9.
# on newer syntax fails here rather than for a user on 3.10.
- uses: actions/setup-python@v5
with:
python-version: "3.9"
python-version: "3.10"

- uses: astral-sh/setup-uv@v5
with:
Expand All @@ -138,7 +136,7 @@ jobs:
uv run --isolated --no-project --with "$wheel" python -c "
from openrouter_agent import call_model, OpenRouter, tool, ModelResult
import importlib.metadata as md
print('imported openrouter-agent', md.version('openrouter-agent'))"
print('imported openrouter-agent-sdk', md.version('openrouter-agent-sdk'))"

- uses: actions/upload-artifact@v4
with:
Expand Down
193 changes: 193 additions & 0 deletions .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
name: Publish

# Publishes `openrouter-agent-sdk` to PyPI using trusted publishing (OIDC) — no
# long-lived API token is stored in this repo.
#
# Publishing is irreversible: a version number can never be reused on PyPI, even
# after a yank. So this workflow is manual-only, defaults to a dry run, and
# refuses to publish a version that already exists on the index.
#
# Release procedure:
# 1. Land the version bump (pyproject.toml `version`). For a port sync that is
# done by scripts/upstream; otherwise edit it in a PR.
# 2. Run this workflow with target=testpypi to rehearse (optional but cheap).
# 3. Run with target=pypi, dry-run=true, and read the summary.
# 4. Run with target=pypi, dry-run=false to release.
#
# One-time setup on PyPI, before the first real publish — the workflow cannot do
# this for you:
# PyPI → the project (or "pending publisher" if it does not exist yet) →
# Publishing → add a GitHub trusted publisher with
# owner: OpenRouterTeam repo: python-agent
# workflow: publish.yaml environment: pypi
# Then create the `pypi` (and `testpypi`) environment in repo Settings, and set
# its deployment branch policy to `main`.
#
# That environment branch policy is the real ref restriction. The `if:` guard
# below stops accidents, not a determined actor: workflow_dispatch runs the
# workflow file from the selected ref, so a branch whose copy drops the guard
# would ignore it. PyPI's trusted publisher pins owner/repo/workflow/environment
# and carries no branch claim, so the environment policy is what actually binds
# publishing to main.

on:
workflow_dispatch:
inputs:
target:
description: "Index to publish to. Rehearse on testpypi first."
required: true
type: choice
options:
- testpypi
- pypi
default: testpypi
dry-run:
description: "Build and verify, but do not upload. Leave enabled until you have read the summary."
required: false
default: true
type: boolean

permissions:
contents: read

concurrency:
group: publish-${{ inputs.target }}
cancel-in-progress: false

jobs:
publish:
runs-on: ubuntu-latest
timeout-minutes: 20
# Selects the trusted-publisher identity and, via its deployment branch
# policy, restricts which refs may publish. A dry run still targets the
# environment so an approval gate is exercised in rehearsal too.
environment: ${{ inputs.target }}
permissions:
contents: read
id-token: write # OIDC token exchange for trusted publishing
# Real publishes only from main; dry runs allowed anywhere so a PR branch can
# verify the artifact without ever reaching the upload step.
if: github.ref == 'refs/heads/main' || inputs.dry-run
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.11"

- uses: astral-sh/setup-uv@v5
with:
enable-cache: true

- run: uv sync --frozen --all-extras

# A broken release is worse than a late one, so re-run the gate here rather
# than trusting that CI passed on some earlier commit. This is the same
# script that gates the port sync.
- name: Verify (lint, types, tests, coverage floor, required API)
run: ./.upstreamer/scripts/verify.sh

- name: Build sdist and wheel
run: |
set -euo pipefail
rm -rf dist
uv build --out-dir dist
ls -l dist

# Catches the metadata problems PyPI rejects on upload — a malformed
# long_description is the classic one, and it fails *after* the version is
# burned if you find out at upload time.
- name: Check metadata renders for PyPI
run: uv run --with twine twine check --strict dist/*

# Proves the artifact, not the source tree: installs the built wheel with
# the repo off sys.path.
- name: Import the public API from the built wheel
run: |
set -euo pipefail
wheel=$(ls dist/*.whl)
uv run --isolated --no-project --with "$wheel" python -c "
from openrouter_agent import call_model, OpenRouter, tool, ModelResult
import importlib.metadata as md
print('imported openrouter-agent-sdk', md.version('openrouter-agent-sdk'))"

# PyPI rejects a re-upload of an existing version with a 400. Failing here
# instead makes the cause obvious ("you forgot to bump") and keeps the
# error out of the upload step.
- name: Confirm this version is not already published
id: version
run: |
set -euo pipefail
VERSION="$(uv run python -c "import importlib.metadata as m; print(m.version('openrouter-agent-sdk'))")"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
if [ "${{ inputs.target }}" = "pypi" ]; then
INDEX="https://pypi.org/pypi/openrouter-agent-sdk/json"
else
INDEX="https://test.pypi.org/pypi/openrouter-agent-sdk/json"
fi
# Collision test done in Python, not by word-splitting a shell string:
# the shell form is subtly non-portable (zsh does not split unquoted
# variables the way bash does), and a guard that silently stops
# matching is worse than no guard — it would wave through the exact
# re-upload it exists to catch. Exit 2 = already published.
if curl -fsSL "$INDEX" -o /tmp/index.json 2>/dev/null; then
python3 - "$VERSION" <<'PY'
import json, sys
version = sys.argv[1]
releases = json.load(open("/tmp/index.json")).get("releases", {})
print("already published:", " ".join(sorted(releases)) or "<none>")
sys.exit(2 if version in releases else 0)
PY
status=$?
if [ "$status" -eq 2 ]; then
echo "::error::Version $VERSION is already published on ${{ inputs.target }}. A PyPI version can never be reused — bump the version in pyproject.toml."
exit 1
elif [ "$status" -ne 0 ]; then
echo "::error::Could not determine published versions (exit $status). Refusing to publish blind."
exit 1
fi
else
echo "Project not on ${{ inputs.target }} yet — this would be the first release."
fi
echo "Version $VERSION is publishable on ${{ inputs.target }}."

- name: Summary
run: |
{
echo "## Publish ${{ inputs.target }}"
echo
echo "- Version: \`${{ steps.version.outputs.version }}\`"
echo "- Dry run: **${{ inputs.dry-run }}**"
echo "- Ref: \`${{ github.ref }}\`"
echo
if [ "${{ inputs.dry-run }}" = "true" ]; then
echo "Nothing was uploaded. Artifacts were built and verified only."
echo "Re-run with dry-run disabled to publish."
else
echo "Uploading to ${{ inputs.target }}."
fi
echo
echo '```'
ls -l dist
echo '```'
} >> "$GITHUB_STEP_SUMMARY"

# Keep the artifacts from a dry run so the exact files that would ship can
# be downloaded and inspected.
- uses: actions/upload-artifact@v4
with:
name: dist-${{ inputs.target }}-${{ steps.version.outputs.version }}
path: dist/

- name: Publish to TestPyPI
if: inputs.target == 'testpypi' && inputs.dry-run == false
uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: https://test.pypi.org/legacy/
print-hash: true

- name: Publish to PyPI
if: inputs.target == 'pypi' && inputs.dry-run == false
uses: pypa/gh-action-pypi-publish@release/v1
with:
print-hash: true
36 changes: 31 additions & 5 deletions .upstreamer/upstreamer.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,37 @@ Explicitly out of scope:
The port sits on the generated `openrouter` Python SDK and must not reimplement
HTTP, auth, retries, or model schemas.

- Target: `openrouter>=0.10.2` (current `pyproject.toml` pin).
- Target: `openrouter>=1.1,<2` (current `pyproject.toml` pin).
- `call_model` sends through `client.beta.responses.send_async` — the Responses
API, matching upstream's Responses path. Do not switch to Chat Completions.

The pin was moved from `>=0.10.2` to `>=1.1,<2` deliberately, in the PR that set
this package up for PyPI publishing — not by a sync run. Two consequences worth
knowing before touching it again:

- **It forced the Python floor to 3.10.** Every `openrouter` 1.x release requires
Python `>=3.10` (the SDK dropped 3.9 at 1.0.0), so `requires-python` is now
`>=3.10`. Python 3.9 reached EOL in October 2025.
- **The upper bound is load-bearing.** A 2.x could move the Responses API surface
this port binds to. `<2` means a new major cannot silently break installs.

Do **not** bump the `openrouter` dependency on your own initiative. Upstream
tracks `@openrouter/sdk`; the Python generated SDK moves independently and is
currently at a much newer major. Crossing that boundary is a breaking change
that needs its own PR. If an upstream change *requires* a newer `openrouter`,
stop and report it as a blocker rather than bumping.
tracks `@openrouter/sdk`; the Python generated SDK moves independently. Crossing
a major boundary is a breaking change that needs its own PR, with the test suite
and `mypy` verified against the new major first. If an upstream change *requires*
a newer `openrouter`, stop and report it as a blocker rather than bumping.

## Package Identity

Fixed. A sync must not change any of these:

| | Value | Why |
|---|---|---|
| PyPI distribution name | `openrouter-agent-sdk` | `openrouter-agent` is taken on PyPI by an unrelated third-party project. Do not "correct" the name to match upstream's `@openrouter/agent`. |
| Import name | `openrouter_agent` | The import path is unaffected by the distribution name and stays aligned with upstream. A PyPI name differing from the import name is normal (`scikit-learn`/`sklearn`). |

`[project.urls]` and `classifiers` are repo-owned publishing metadata, not port
output. Leave them alone.

## Package Version

Expand All @@ -47,6 +69,10 @@ from the upstream `packages/agent/package.json` at the target commit and set it
to match. If the target commit is between releases, keep the last released
version and note the drift in the final report.

Publishing is gated on this: a released version can never be reused on PyPI, so a
sync that bumps the version is what makes the next release possible. Never bump it
past what was actually ported.

## Required Public API

Every symbol below must be importable from `openrouter_agent` and behaviorally
Expand Down
38 changes: 38 additions & 0 deletions PORTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,44 @@ This is a load-bearing SDK port behind a strict parity eval — use a strong cod
model. `OPENCODE_MODEL` overrides the `model:` field in the contract, so you can
change models without a code change.

## Releasing to PyPI

The distribution is **`openrouter-agent-sdk`**; the import stays
`openrouter_agent`. The obvious name `openrouter-agent` is taken on PyPI by an
unrelated third-party project, so a sync must not "correct" it — see the Package
Identity table in `.upstreamer/upstreamer.md`.

`.github/workflows/publish.yaml` is manual-only (`workflow_dispatch`), defaults to
a dry run, and uses PyPI **trusted publishing (OIDC)** — no API token is stored in
this repo.

```
1. Land the version bump in pyproject.toml (a port sync does this).
2. Run Publish with target=testpypi to rehearse.
3. Run with target=pypi, dry-run=true — read the summary.
4. Run with target=pypi, dry-run=false to release.
```

Before the first real publish, two things must be set up by hand — the workflow
cannot do them for you:

1. **On PyPI**: add a GitHub trusted publisher (owner `OpenRouterTeam`, repo
`python-agent`, workflow `publish.yaml`, environment `pypi`). If the project
does not exist yet, add it as a *pending* publisher.
2. **In repo Settings**: create the `pypi` and `testpypi` environments and set each
one's deployment branch policy to `main`.

That branch policy is the real ref restriction. PyPI's trusted publisher pins
owner/repo/workflow/environment but carries no branch claim, and
`workflow_dispatch` runs the workflow file from whatever ref is selected — so the
in-file `if:` guard stops accidents, while the environment policy is what actually
binds publishing to `main`.

Publishing is irreversible: a version can never be reused on PyPI, even after a
yank. The workflow re-runs `verify.sh`, checks metadata with `twine check
--strict`, imports the built wheel in isolation, and refuses to upload a version
that already exists on the target index.

## Reviewing a port PR

Review it as a *port*, not a normal diff:
Expand Down
Loading
Loading