Skip to content

Commit 6ad4f30

Browse files
authored
[experimental] Add OpenReward Standard environment adapter (#5696)
1 parent ca8d909 commit 6ad4f30

11 files changed

Lines changed: 1347 additions & 0 deletions

File tree

docs/source/_toctree.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@
5151
title: Liger Kernel
5252
- local: openenv
5353
title: OpenEnv
54+
- local: openreward
55+
title: OpenReward
5456
- local: peft_integration
5557
title: PEFT
5658
- local: ptt_integration

docs/source/openreward.md

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
# OpenReward Integration for Training LLMs with Environments
2+
3+
[OpenReward](https://openreward.ai) is an open ecosystem for RL environments built on the [Open Reward Standard (ORS)](https://openrewardstandard.io) — a public, language-agnostic HTTP/SSE protocol for how an environment exposes its tasks, tools, sessions, and rewards. Because ORS is just a protocol, the same environment can run on the [OpenReward platform](https://openreward.ai), self-hosted on any container service, or locally on `localhost` for development. A catalog of ready-to-use environments is available at [openreward.ai](https://openreward.ai).
4+
5+
This guide covers **how to integrate OpenReward with TRL**. For more on the standard itself, see the [ORS docs](https://docs.openreward.ai/).
6+
7+
> [!NOTE]
8+
> The integration lives at `trl.experimental.openreward` and is gated behind the `trl[openreward]` extra (lazy-imported — non-users pay nothing).
9+
10+
## When to use OpenReward environments
11+
12+
[`GRPOTrainer`] supports environment-based training via the `environment_factory` slot — see [OpenEnv](openenv) for the general contract. Use OpenReward when you want to train against an ORS-speaking environment: the [OpenReward catalog](https://openreward.ai) (e.g. `Eigent/SETA`, `kanishk/EndlessTerminals`, `nebius/SWE-rebench-V2`), an env you self-host on your own infra, or a local server you're developing.
13+
14+
## Installation
15+
16+
```bash
17+
pip install trl[openreward]
18+
```
19+
20+
This installs the `openreward` Python SDK. The integration itself imports `openreward` lazily, so users who don't touch `trl.experimental.openreward` aren't affected.
21+
22+
## Quick start
23+
24+
The `OpenRewardSpec` class wires a single ORS environment into the three TRL trainer slots — `train_dataset`, `environment_factory`, `reward_funcs` — by exposing properties that map 1:1 to those kwarg names:
25+
26+
```python
27+
from trl import GRPOConfig, GRPOTrainer
28+
from trl.experimental.openreward import OpenRewardSpec
29+
30+
spec = OpenRewardSpec("Eigent/SETA", num_tasks=64)
31+
32+
trainer = GRPOTrainer(
33+
model="Qwen/Qwen3-4B",
34+
args=GRPOConfig(
35+
num_generations=2,
36+
max_steps=5,
37+
max_tool_calling_iterations=20,
38+
log_completions=True,
39+
),
40+
train_dataset=spec.train_dataset,
41+
environment_factory=spec.environment_factory,
42+
reward_funcs=spec.reward_funcs,
43+
)
44+
trainer.train()
45+
```
46+
47+
Under the hood `OpenRewardSpec` does three things, lazily on first access:
48+
49+
1. **`spec.train_dataset`**: derives a `datasets.Dataset` from the env's task list (one HTTP roundtrip via the SDK). Has at minimum `prompt`, `task_index`, plus per-task metadata columns folded in.
50+
2. **`spec.environment_factory`**: returns a zero-arg callable that produces a fresh per-rollout adapter on each call. The adapter exposes one Python method per ORS tool, with a typed signature and docstring auto-generated from the env's JSON Schema. TRL's tool collector picks them up via `inspect.getmembers`.
51+
3. **`spec.reward_funcs`**: an outcome-only reward function (last non-null reward in the trajectory) suitable for sparse-reward envs like SETA.
52+
53+
## Using a hub environment
54+
55+
Pass an [openreward.ai](https://openreward.ai) catalog name as the target. The SDK reads `OPENREWARD_API_KEY` from the environment for authentication.
56+
57+
```python
58+
spec = OpenRewardSpec("Eigent/SETA", num_tasks=64)
59+
```
60+
61+
## Using a self-hosted environment
62+
63+
Pass the URL directly. No API key is needed if your server doesn't enforce one.
64+
65+
```python
66+
spec = OpenRewardSpec("https://my-org-my-env.hf.space", env_name="my_env")
67+
```
68+
69+
> [!IMPORTANT]
70+
> The `openreward` SDK by default expects a two-subdomain platform layout (`api.<host>` for stateless calls and `sessions.<host>` for SSE-based session calls). For **single-host** self-hosted servers (one URL serving everything), set the override env vars below before constructing `OpenRewardSpec`:
71+
>
72+
> ```python
73+
> import os
74+
>
75+
> URL = "https://my-org-my-env.hf.space"
76+
> os.environ["OPENREWARD_API_URL"] = URL
77+
> os.environ["OPENREWARD_SESSION_URL"] = URL
78+
>
79+
> spec = OpenRewardSpec(URL, env_name="my_env")
80+
> ```
81+
82+
## Running a minimal environment locally
83+
84+
The fastest way to try the integration end-to-end without external dependencies is a tiny ORS server defined with the `openreward` SDK's `Environment` + `Server` scaffolding. The example below is a complete `echo` environment — the model wins by calling `echo(text=...)` with the task's target string.
85+
86+
```python
87+
# server.py
88+
from pydantic import BaseModel
89+
from openreward.environments import Environment, JSONObject, Server, TextBlock, ToolOutput, tool
90+
91+
92+
class EchoTaskSpec(BaseModel):
93+
target: str
94+
95+
class EchoParams(BaseModel):
96+
text: str
97+
98+
99+
class EchoEnvironment(Environment):
100+
def __init__(self, task_spec: JSONObject = {}, secrets: dict[str, str] = {}):
101+
super().__init__(task_spec)
102+
self.config = EchoTaskSpec.model_validate(task_spec)
103+
104+
@classmethod
105+
def list_splits(cls) -> list[str]:
106+
return ["train"]
107+
108+
@classmethod
109+
def list_tasks(cls, split: str) -> list[JSONObject]:
110+
return [{"target": "hello"}, {"target": "world"}]
111+
112+
def get_prompt(self) -> list[TextBlock]:
113+
return [TextBlock(type="text", text=f"Echo '{self.config.target}' to win.")]
114+
115+
@tool
116+
async def echo(self, params: EchoParams) -> ToolOutput:
117+
"""Submit a string. Reward 1.0 + finished if it matches the target.
118+
119+
Args:
120+
text: The string to echo back.
121+
"""
122+
correct = params.text == self.config.target
123+
return ToolOutput(
124+
blocks=[TextBlock(type="text", text="match" if correct else "no match")],
125+
reward=1.0 if correct else 0.0,
126+
finished=correct,
127+
)
128+
129+
130+
if __name__ == "__main__":
131+
Server([EchoEnvironment]).run(host="0.0.0.0", port=8000)
132+
```
133+
134+
Run it:
135+
136+
```bash
137+
pip install openreward fastapi uvicorn pydantic
138+
python server.py # listens on :8000
139+
```
140+
141+
Then point `OpenRewardSpec` at it (with the URL overrides described above):
142+
143+
```python
144+
import os
145+
URL = "http://127.0.0.1:8000"
146+
os.environ["OPENREWARD_API_URL"] = URL
147+
os.environ["OPENREWARD_SESSION_URL"] = URL
148+
149+
from trl.experimental.openreward import OpenRewardSpec
150+
spec = OpenRewardSpec(URL, env_name="echoenvironment")
151+
print(spec.train_dataset) # 2 rows, task_index + target columns
152+
```
153+
154+
This is also the fixture pattern used by TRL's own tests — see [`trl-internal-testing/openreward-echo-env`](https://huggingface.co/spaces/trl-internal-testing/openreward-echo-env) for the deployed Space.
155+
156+
## Selecting tasks
157+
158+
`OpenRewardSpec` accepts either a count or an explicit index list:
159+
160+
```python
161+
spec = OpenRewardSpec("Eigent/SETA", num_tasks=10) # first 10 tasks
162+
spec = OpenRewardSpec("Eigent/SETA", indices=[0, 5, 13, 27]) # specific indices
163+
spec = OpenRewardSpec("Eigent/SETA", indices=list(range(50, 100))) # range
164+
```
165+
166+
`num_tasks` and `indices` are mutually exclusive and both fetch only the tasks they need (no full task list scan).
167+
168+
## How tool binding works
169+
170+
At construction the spec calls the env's `/tools` endpoint to fetch a list of tool specs (each with a name, description, and JSON Schema for arguments). For each tool it generates a Python method on the per-rollout adapter with a typed signature and a docstring derived from the schema. So `transformers.utils.get_json_schema` and TRL's `inspect.getmembers(env, ismethod)` both produce the right tool schema for the model with no per-env wrapper code.
171+
172+
If a tool description contains characters that aren't safe to splice into Python source, the binder falls back to a sanitized form so binding never fails on real envs.
173+
174+
## Reward functions
175+
176+
`spec.reward_funcs` defaults to an outcome-only reward — for each rollout it returns the last non-null reward observed during the trajectory. This is the right default for sparse-reward envs (e.g. SETA, where only `submit_solution` returns a non-null reward).
177+
178+
If you want a custom reward, write a regular TRL reward function and pass it directly:
179+
180+
```python
181+
def my_reward(environments, **kwargs) -> list[float]:
182+
return [env.reward * 2.0 for env in environments] # double the env reward, etc.
183+
184+
trainer = GRPOTrainer(
185+
...,
186+
reward_funcs=my_reward,
187+
)
188+
```
189+
190+
The per-rollout adapter exposes the running state TRL needs — `env.reward`, `env.rewards`, `env.metadata`, `env.finished`, `env.last_output` — for arbitrary post-hoc reward shaping.
191+
192+
## OpenRewardSpec
193+
194+
[[autodoc]] trl.experimental.openreward.OpenRewardSpec
195+
196+
## Limitations
197+
198+
- The integration is in `trl.experimental` — APIs may change. Set `TRL_EXPERIMENTAL_SILENCE=1` to silence the warning in CI logs.
199+
- Currently exposes a single `OpenRewardSpec` covering one environment; multi-environment training (à la the OpenEnv "meta-environment" pattern) is not supported yet.
200+
- Long-running rollouts (>15 min per episode) need a keepalive ping — not yet wired.
201+
202+
## Reference
203+
204+
- [Open Reward Standard](https://openrewardstandard.io)
205+
- [OpenReward platform](https://openreward.ai)
206+
- [`openreward` Python SDK](https://pypi.org/project/openreward/)
207+
- [Echo env Space — `trl-internal-testing/openreward-echo-env`](https://huggingface.co/spaces/trl-internal-testing/openreward-echo-env)
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# Copyright 2020-2026 The HuggingFace Team. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
# /// script
16+
# dependencies = ["trl[vllm,openreward]"]
17+
# ///
18+
19+
"""GRPO training against the SETA ORS environment.
20+
21+
Defaults target ``Eigent/SETA`` on the openreward.ai catalog (requires
22+
``OPENREWARD_API_KEY``). Pass ``--target https://...hf.space`` to point
23+
at a self-hosted Space.
24+
25+
Usage (colocate vLLM, single-node):
26+
27+
```sh
28+
accelerate launch \
29+
--config_file examples/accelerate_configs/deepspeed_zero2.yaml \
30+
--num_processes 4 \
31+
examples/scripts/openreward/seta.py \
32+
--vllm-mode colocate
33+
```
34+
35+
Usage (server vLLM, single-node 2+2 GPU split):
36+
37+
```sh
38+
# Terminal 1 — vLLM
39+
CUDA_VISIBLE_DEVICES=2,3 trl vllm-serve --model Qwen/Qwen3-4B \
40+
--tensor-parallel-size 2 --port 8000
41+
42+
# Terminal 2 — training
43+
CUDA_VISIBLE_DEVICES=0,1 accelerate launch \
44+
--config_file examples/accelerate_configs/deepspeed_zero2.yaml \
45+
--num_processes 2 \
46+
examples/scripts/openreward/seta.py \
47+
--vllm-mode server --vllm-server-base-url http://localhost:8000
48+
```
49+
"""
50+
51+
import argparse
52+
53+
from trl import GRPOConfig, GRPOTrainer
54+
from trl.experimental.openreward import OpenRewardSpec
55+
56+
57+
def parse_args() -> argparse.Namespace:
58+
parser = argparse.ArgumentParser(description="GRPO training against the SETA ORS environment.")
59+
60+
parser.add_argument("--model", type=str, default="Qwen/Qwen3-4B")
61+
parser.add_argument(
62+
"--target",
63+
type=str,
64+
default="Eigent/SETA",
65+
help="ORS env target — either a catalog name (e.g. 'Eigent/SETA') or a URL "
66+
"(e.g. 'https://you-seta.hf.space').",
67+
)
68+
parser.add_argument("--split", type=str, default="train")
69+
parser.add_argument("--num-tasks", type=int, default=64)
70+
71+
parser.add_argument("--learning-rate", type=float, default=5e-7)
72+
parser.add_argument("--per-device-train-batch-size", type=int, default=1)
73+
parser.add_argument("--gradient-accumulation-steps", type=int, default=1)
74+
parser.add_argument("--num-generations", type=int, default=2)
75+
parser.add_argument("--max-completion-length", type=int, default=2048)
76+
parser.add_argument("--max-steps", type=int, default=5)
77+
parser.add_argument("--max-tool-calling-iterations", type=int, default=20)
78+
79+
parser.add_argument("--vllm-mode", choices=("colocate", "server"), default="colocate")
80+
parser.add_argument("--vllm-server-base-url", type=str, default="http://localhost:8000")
81+
parser.add_argument("--vllm-gpu-memory-utilization", type=float, default=0.3)
82+
83+
parser.add_argument("--output-dir", type=str, default=None)
84+
parser.add_argument("--report-to", type=str, default="none")
85+
86+
return parser.parse_args()
87+
88+
89+
def main() -> None:
90+
args = parse_args()
91+
92+
# One spec object — fans out into TRL's three slots.
93+
spec = OpenRewardSpec(args.target, num_tasks=args.num_tasks, split=args.split)
94+
95+
config_kwargs: dict = dict(
96+
learning_rate=args.learning_rate,
97+
per_device_train_batch_size=args.per_device_train_batch_size,
98+
gradient_accumulation_steps=args.gradient_accumulation_steps,
99+
num_generations=args.num_generations,
100+
max_completion_length=args.max_completion_length,
101+
max_steps=args.max_steps,
102+
max_tool_calling_iterations=args.max_tool_calling_iterations,
103+
chat_template_kwargs={"enable_thinking": False},
104+
log_completions=True,
105+
use_vllm=True,
106+
vllm_mode=args.vllm_mode,
107+
report_to=[s.strip() for s in args.report_to.split(",") if s.strip() and s.strip() != "none"] or "none",
108+
)
109+
if args.output_dir:
110+
config_kwargs["output_dir"] = args.output_dir
111+
if args.vllm_mode == "colocate":
112+
config_kwargs["vllm_gpu_memory_utilization"] = args.vllm_gpu_memory_utilization
113+
else:
114+
config_kwargs["vllm_server_base_url"] = args.vllm_server_base_url
115+
116+
trainer = GRPOTrainer(
117+
model=args.model,
118+
args=GRPOConfig(**config_kwargs),
119+
train_dataset=spec.train_dataset,
120+
environment_factory=spec.environment_factory,
121+
reward_funcs=spec.reward_funcs,
122+
)
123+
trainer.train()
124+
125+
126+
if __name__ == "__main__":
127+
main()

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ vlm = [
9494
math_verify = [
9595
"math-verify>=0.5.2",
9696
]
97+
openreward = [
98+
"openreward>=0.1.109; python_version >= '3.11'", # openreward requires Python 3.11+
99+
]
97100
dev = [
98101
# bco
99102
"scikit-learn",
@@ -104,6 +107,8 @@ dev = [
104107
"kernels",
105108
# liger
106109
"liger-kernel>=0.8.0",
110+
# openreward (requires Python 3.11+)
111+
"openreward>=0.1.109; python_version >= '3.11'",
107112
# peft
108113
"peft>=0.8.0",
109114
# quality

0 commit comments

Comments
 (0)