|
| 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) |
0 commit comments