Skip to content

Commit d150dbc

Browse files
LukasParkeclaude
andcommitted
release: rename to openrouter-agent-sdk, move to openrouter 1.x, add PyPI publishing
This package could not be published as-is. Two blockers, found by checking the index rather than assuming: 1. `openrouter-agent` is ALREADY TAKEN on PyPI — by an unrelated third-party Pydantic AI integration (VinnyVanGogh, v0.1.3, last released 2025-04-21). Not ours, not abandoned enough to assume, and not usable. 2. The dependency pin `openrouter>=0.10.2` was unbounded while PyPI's latest is 1.1.22, so a fresh `pip install` would resolve a major version the port had never been tested against. Package identity Renamed the distribution to `openrouter-agent-sdk`. The import stays `openrouter_agent` — a PyPI name differing from the import name is normal (scikit-learn/sklearn), and keeping the import aligned with upstream avoids churning every consumer's code and every doc example. Recorded as a fixed Package Identity table in .upstreamer/upstreamer.md so a sync does not "correct" the name back to match upstream's @openrouter/agent. Substrate: openrouter>=1.1,<2, and Python floor 3.10 Verified before bumping, not after: all 114 tests AND mypy pass against openrouter 1.1.22, and the shared fixtures still validate against 1.x's OpenResponsesResult (same 18 required fields). The bump forces dropping Python 3.9: every openrouter 1.x release requires >=3.10 — the SDK dropped 3.9 exactly at 1.0.0, with 0.10.8 the last 3.9-capable release. Python 3.9 reached EOL in October 2025, so `requires-python` is now ">=3.10" and the CI matrix is 3.10/3.11/3.13. Both new legs verified passing; 3.9 now correctly refuses to resolve. The upper bound `<2` is deliberate: a 2.x could move the Responses API surface this port binds to, and an unbounded floor is how the original problem happened. The contract's substrate-pin section forbids bumping this dependency on the port's own initiative, so it is updated in the same commit to authorize the new pin and record both consequences — otherwise the next sync reverts it or reports it as drift. Packaging metadata - Added [project.urls] (repository, issues, changelog, upstream) and trove classifiers. There was no repository link on the package metadata at all. - Added [tool.hatch.build.targets.sdist] include list. The sdist was shipping the entire porting apparatus — .upstreamer/ (contract, eval prompts, skills), .github/, opencode.json, scripts/upstream. None of it helps someone building from source, and shipping the contract invites confusion about what the package is. Now: src, tests, README, PORTING, LICENSE, pyproject, changelog. - Confirmed py.typed ships in the wheel (the README claims it does). Publish workflow .github/workflows/publish.yaml, using PyPI trusted publishing (OIDC) — no API token stored in this repo. Manual-only, defaults to dry-run, targets testpypi or pypi. Because publishing is irreversible — a version can never be reused, even after a yank — it re-runs verify.sh rather than trusting an earlier CI pass, checks metadata with `twine check --strict`, imports the built wheel in isolation, and refuses to upload a version already present on the target index. The version-collision guard is implemented 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 I caught it failing to detect a real collision while testing it — a guard that silently stops matching is worse than no guard, since it would wave through exactly the re-upload it exists to prevent. Verified in all three states: collision refused, non-collision allowed, new project allowed. Verification verify.sh PASS (0 failures) · mypy src tests clean · 114 passed · coverage 83.89% over an 83% floor · 31/31 required symbols · twine check --strict PASSED on both artifacts · wheel imports in an isolated env · 3.10 and 3.13 both green. Not done here, deliberately: nothing is published. The first release needs two manual setup steps the workflow cannot perform — a PyPI trusted publisher (owner/repo/workflow/environment) and repo environments `pypi`/`testpypi` with their deployment branch policy set to main. That policy is the real ref restriction; PyPI's trusted publisher carries no branch claim, and workflow_dispatch runs the workflow file from whatever ref is selected, so the in-file guard stops accidents while the environment policy is what binds publishing to main. Both documented in PORTING.md. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 945376a commit d150dbc

7 files changed

Lines changed: 455 additions & 349 deletions

File tree

‎.github/workflows/ci.yaml‎

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ name: CI
77
# src, coverage that cannot silently decay, and an installable wheel.
88
#
99
# Required checks (branch protection):
10-
# check (py3.9) · check (py3.11) · check (py3.13) · types · build · verify-port
10+
# check (py3.10) · check (py3.11) · check (py3.13) · types · build · verify-port
1111
# Deliberately NOT required: e2e — it exits 0 when the API key is absent (forks),
1212
# so requiring it would be a green rubber stamp.
1313

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

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

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

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

125123
- uses: astral-sh/setup-uv@v5
126124
with:

‎.github/workflows/publish.yaml‎

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
name: Publish
2+
3+
# Publishes `openrouter-agent-sdk` to PyPI using trusted publishing (OIDC) — no
4+
# long-lived API token is stored in this repo.
5+
#
6+
# Publishing is irreversible: a version number can never be reused on PyPI, even
7+
# after a yank. So this workflow is manual-only, defaults to a dry run, and
8+
# refuses to publish a version that already exists on the index.
9+
#
10+
# Release procedure:
11+
# 1. Land the version bump (pyproject.toml `version`). For a port sync that is
12+
# done by scripts/upstream; otherwise edit it in a PR.
13+
# 2. Run this workflow with target=testpypi to rehearse (optional but cheap).
14+
# 3. Run with target=pypi, dry-run=true, and read the summary.
15+
# 4. Run with target=pypi, dry-run=false to release.
16+
#
17+
# One-time setup on PyPI, before the first real publish — the workflow cannot do
18+
# this for you:
19+
# PyPI → the project (or "pending publisher" if it does not exist yet) →
20+
# Publishing → add a GitHub trusted publisher with
21+
# owner: OpenRouterTeam repo: python-agent
22+
# workflow: publish.yaml environment: pypi
23+
# Then create the `pypi` (and `testpypi`) environment in repo Settings, and set
24+
# its deployment branch policy to `main`.
25+
#
26+
# That environment branch policy is the real ref restriction. The `if:` guard
27+
# below stops accidents, not a determined actor: workflow_dispatch runs the
28+
# workflow file from the selected ref, so a branch whose copy drops the guard
29+
# would ignore it. PyPI's trusted publisher pins owner/repo/workflow/environment
30+
# and carries no branch claim, so the environment policy is what actually binds
31+
# publishing to main.
32+
33+
on:
34+
workflow_dispatch:
35+
inputs:
36+
target:
37+
description: "Index to publish to. Rehearse on testpypi first."
38+
required: true
39+
type: choice
40+
options:
41+
- testpypi
42+
- pypi
43+
default: testpypi
44+
dry-run:
45+
description: "Build and verify, but do not upload. Leave enabled until you have read the summary."
46+
required: false
47+
default: true
48+
type: boolean
49+
50+
permissions:
51+
contents: read
52+
53+
concurrency:
54+
group: publish-${{ inputs.target }}
55+
cancel-in-progress: false
56+
57+
jobs:
58+
publish:
59+
runs-on: ubuntu-latest
60+
timeout-minutes: 20
61+
# Selects the trusted-publisher identity and, via its deployment branch
62+
# policy, restricts which refs may publish. A dry run still targets the
63+
# environment so an approval gate is exercised in rehearsal too.
64+
environment: ${{ inputs.target }}
65+
permissions:
66+
contents: read
67+
id-token: write # OIDC token exchange for trusted publishing
68+
# Real publishes only from main; dry runs allowed anywhere so a PR branch can
69+
# verify the artifact without ever reaching the upload step.
70+
if: github.ref == 'refs/heads/main' || inputs.dry-run
71+
steps:
72+
- uses: actions/checkout@v4
73+
74+
- uses: actions/setup-python@v5
75+
with:
76+
python-version: "3.11"
77+
78+
- uses: astral-sh/setup-uv@v5
79+
with:
80+
enable-cache: true
81+
82+
- run: uv sync --frozen --all-extras
83+
84+
# A broken release is worse than a late one, so re-run the gate here rather
85+
# than trusting that CI passed on some earlier commit. This is the same
86+
# script that gates the port sync.
87+
- name: Verify (lint, types, tests, coverage floor, required API)
88+
run: ./.upstreamer/scripts/verify.sh
89+
90+
- name: Build sdist and wheel
91+
run: |
92+
set -euo pipefail
93+
rm -rf dist
94+
uv build --out-dir dist
95+
ls -l dist
96+
97+
# Catches the metadata problems PyPI rejects on upload — a malformed
98+
# long_description is the classic one, and it fails *after* the version is
99+
# burned if you find out at upload time.
100+
- name: Check metadata renders for PyPI
101+
run: uv run --with twine twine check --strict dist/*
102+
103+
# Proves the artifact, not the source tree: installs the built wheel with
104+
# the repo off sys.path.
105+
- name: Import the public API from the built wheel
106+
run: |
107+
set -euo pipefail
108+
wheel=$(ls dist/*.whl)
109+
uv run --isolated --no-project --with "$wheel" python -c "
110+
from openrouter_agent import call_model, OpenRouter, tool, ModelResult
111+
import importlib.metadata as md
112+
print('imported openrouter-agent-sdk', md.version('openrouter-agent-sdk'))"
113+
114+
# PyPI rejects a re-upload of an existing version with a 400. Failing here
115+
# instead makes the cause obvious ("you forgot to bump") and keeps the
116+
# error out of the upload step.
117+
- name: Confirm this version is not already published
118+
id: version
119+
run: |
120+
set -euo pipefail
121+
VERSION="$(uv run python -c "import importlib.metadata as m; print(m.version('openrouter-agent-sdk'))")"
122+
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
123+
if [ "${{ inputs.target }}" = "pypi" ]; then
124+
INDEX="https://pypi.org/pypi/openrouter-agent-sdk/json"
125+
else
126+
INDEX="https://test.pypi.org/pypi/openrouter-agent-sdk/json"
127+
fi
128+
# Collision test done in Python, not by word-splitting a shell string:
129+
# the shell form is subtly non-portable (zsh does not split unquoted
130+
# variables the way bash does), and a guard that silently stops
131+
# matching is worse than no guard — it would wave through the exact
132+
# re-upload it exists to catch. Exit 2 = already published.
133+
if curl -fsSL "$INDEX" -o /tmp/index.json 2>/dev/null; then
134+
python3 - "$VERSION" <<'PY'
135+
import json, sys
136+
version = sys.argv[1]
137+
releases = json.load(open("/tmp/index.json")).get("releases", {})
138+
print("already published:", " ".join(sorted(releases)) or "<none>")
139+
sys.exit(2 if version in releases else 0)
140+
PY
141+
status=$?
142+
if [ "$status" -eq 2 ]; then
143+
echo "::error::Version $VERSION is already published on ${{ inputs.target }}. A PyPI version can never be reused — bump the version in pyproject.toml."
144+
exit 1
145+
elif [ "$status" -ne 0 ]; then
146+
echo "::error::Could not determine published versions (exit $status). Refusing to publish blind."
147+
exit 1
148+
fi
149+
else
150+
echo "Project not on ${{ inputs.target }} yet — this would be the first release."
151+
fi
152+
echo "Version $VERSION is publishable on ${{ inputs.target }}."
153+
154+
- name: Summary
155+
run: |
156+
{
157+
echo "## Publish ${{ inputs.target }}"
158+
echo
159+
echo "- Version: \`${{ steps.version.outputs.version }}\`"
160+
echo "- Dry run: **${{ inputs.dry-run }}**"
161+
echo "- Ref: \`${{ github.ref }}\`"
162+
echo
163+
if [ "${{ inputs.dry-run }}" = "true" ]; then
164+
echo "Nothing was uploaded. Artifacts were built and verified only."
165+
echo "Re-run with dry-run disabled to publish."
166+
else
167+
echo "Uploading to ${{ inputs.target }}."
168+
fi
169+
echo
170+
echo '```'
171+
ls -l dist
172+
echo '```'
173+
} >> "$GITHUB_STEP_SUMMARY"
174+
175+
# Keep the artifacts from a dry run so the exact files that would ship can
176+
# be downloaded and inspected.
177+
- uses: actions/upload-artifact@v4
178+
with:
179+
name: dist-${{ inputs.target }}-${{ steps.version.outputs.version }}
180+
path: dist/
181+
182+
- name: Publish to TestPyPI
183+
if: inputs.target == 'testpypi' && inputs.dry-run == false
184+
uses: pypa/gh-action-pypi-publish@release/v1
185+
with:
186+
repository-url: https://test.pypi.org/legacy/
187+
print-hash: true
188+
189+
- name: Publish to PyPI
190+
if: inputs.target == 'pypi' && inputs.dry-run == false
191+
uses: pypa/gh-action-pypi-publish@release/v1
192+
with:
193+
print-hash: true

‎.upstreamer/upstreamer.md‎

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,37 @@ Explicitly out of scope:
3030
The port sits on the generated `openrouter` Python SDK and must not reimplement
3131
HTTP, auth, retries, or model schemas.
3232

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

37+
The pin was moved from `>=0.10.2` to `>=1.1,<2` deliberately, in the PR that set
38+
this package up for PyPI publishing — not by a sync run. Two consequences worth
39+
knowing before touching it again:
40+
41+
- **It forced the Python floor to 3.10.** Every `openrouter` 1.x release requires
42+
Python `>=3.10` (the SDK dropped 3.9 at 1.0.0), so `requires-python` is now
43+
`>=3.10`. Python 3.9 reached EOL in October 2025.
44+
- **The upper bound is load-bearing.** A 2.x could move the Responses API surface
45+
this port binds to. `<2` means a new major cannot silently break installs.
46+
3747
Do **not** bump the `openrouter` dependency on your own initiative. Upstream
38-
tracks `@openrouter/sdk`; the Python generated SDK moves independently and is
39-
currently at a much newer major. Crossing that boundary is a breaking change
40-
that needs its own PR. If an upstream change *requires* a newer `openrouter`,
41-
stop and report it as a blocker rather than bumping.
48+
tracks `@openrouter/sdk`; the Python generated SDK moves independently. Crossing
49+
a major boundary is a breaking change that needs its own PR, with the test suite
50+
and `mypy` verified against the new major first. If an upstream change *requires*
51+
a newer `openrouter`, stop and report it as a blocker rather than bumping.
52+
53+
## Package Identity
54+
55+
Fixed. A sync must not change any of these:
56+
57+
| | Value | Why |
58+
|---|---|---|
59+
| 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`. |
60+
| 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`). |
61+
62+
`[project.urls]` and `classifiers` are repo-owned publishing metadata, not port
63+
output. Leave them alone.
4264

4365
## Package Version
4466

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

72+
Publishing is gated on this: a released version can never be reused on PyPI, so a
73+
sync that bumps the version is what makes the next release possible. Never bump it
74+
past what was actually ported.
75+
5076
## Required Public API
5177

5278
Every symbol below must be importable from `openrouter_agent` and behaviorally

‎PORTING.md‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,44 @@ This is a load-bearing SDK port behind a strict parity eval — use a strong cod
108108
model. `OPENCODE_MODEL` overrides the `model:` field in the contract, so you can
109109
change models without a code change.
110110

111+
## Releasing to PyPI
112+
113+
The distribution is **`openrouter-agent-sdk`**; the import stays
114+
`openrouter_agent`. The obvious name `openrouter-agent` is taken on PyPI by an
115+
unrelated third-party project, so a sync must not "correct" it — see the Package
116+
Identity table in `.upstreamer/upstreamer.md`.
117+
118+
`.github/workflows/publish.yaml` is manual-only (`workflow_dispatch`), defaults to
119+
a dry run, and uses PyPI **trusted publishing (OIDC)** — no API token is stored in
120+
this repo.
121+
122+
```
123+
1. Land the version bump in pyproject.toml (a port sync does this).
124+
2. Run Publish with target=testpypi to rehearse.
125+
3. Run with target=pypi, dry-run=true — read the summary.
126+
4. Run with target=pypi, dry-run=false to release.
127+
```
128+
129+
Before the first real publish, two things must be set up by hand — the workflow
130+
cannot do them for you:
131+
132+
1. **On PyPI**: add a GitHub trusted publisher (owner `OpenRouterTeam`, repo
133+
`python-agent`, workflow `publish.yaml`, environment `pypi`). If the project
134+
does not exist yet, add it as a *pending* publisher.
135+
2. **In repo Settings**: create the `pypi` and `testpypi` environments and set each
136+
one's deployment branch policy to `main`.
137+
138+
That branch policy is the real ref restriction. PyPI's trusted publisher pins
139+
owner/repo/workflow/environment but carries no branch claim, and
140+
`workflow_dispatch` runs the workflow file from whatever ref is selected — so the
141+
in-file `if:` guard stops accidents, while the environment policy is what actually
142+
binds publishing to `main`.
143+
144+
Publishing is irreversible: a version can never be reused on PyPI, even after a
145+
yank. The workflow re-runs `verify.sh`, checks metadata with `twine check
146+
--strict`, imports the built wheel in isolation, and refuses to upload a version
147+
that already exists on the target index.
148+
111149
## Reviewing a port PR
112150

113151
Review it as a *port*, not a normal diff:

‎README.md‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
# openrouter-agent
1+
# openrouter-agent-sdk
22

3-
`openrouter-agent` is a Python agent toolkit for OpenRouter. It ports the public behavior of `@openrouter/agent` into an async-first Python package: Responses API calls, client and server tools, streaming consumption, multi-turn state, approval and human-in-the-loop gates, tool context, stop conditions, and Claude/OpenAI Chat format compatibility.
3+
`openrouter-agent-sdk` is a Python agent toolkit for OpenRouter. It ports the public behavior of `@openrouter/agent` into an async-first Python package: Responses API calls, client and server tools, streaming consumption, multi-turn state, approval and human-in-the-loop gates, tool context, stop conditions, and Claude/OpenAI Chat format compatibility.
44

55
This package builds on the official `openrouter` Python SDK. It does not reimplement HTTP, auth, retries, or model schemas; `call_model` sends requests through `client.beta.responses.send_async`, the same Responses API surface used by the TypeScript package.
66

@@ -10,11 +10,19 @@ This package builds on the official `openrouter` Python SDK. It does not reimple
1010
## Install
1111

1212
```bash
13-
pip install openrouter-agent
13+
pip install openrouter-agent-sdk
1414
# or
15-
uv add openrouter-agent
15+
uv add openrouter-agent-sdk
1616
```
1717

18+
The distribution is `openrouter-agent-sdk`; the import is `openrouter_agent`:
19+
20+
```python
21+
from openrouter_agent import call_model, tool
22+
```
23+
24+
Requires Python 3.10+ (the `openrouter` SDK dropped 3.9 at 1.0.0).
25+
1826
## Quick Start
1927

2028
```python
@@ -211,7 +219,7 @@ Tests share fixtures from `tests/_fixtures.py` — `make_response`,
211219
`make_response` populates every field the real Responses API returns, so a stub
212220
cannot be more permissive than production.
213221

214-
CI runs the suite on Python 3.9, 3.11, and 3.13, type-checks `src` and `tests`,
222+
CI runs the suite on Python 3.10, 3.11, and 3.13, type-checks `src` and `tests`,
215223
enforces a coverage floor, and verifies the built wheel imports in isolation.
216224
Because this package is a port, tests are held to upstream behavior — see the
217225
Test Parity section of `.upstreamer/upstreamer.md` and [PORTING.md](PORTING.md).

0 commit comments

Comments
 (0)