Skip to content

Commit 7160b8b

Browse files
Add recursive RLM client factory injection
1 parent 72d6940 commit 7160b8b

4 files changed

Lines changed: 144 additions & 11 deletions

File tree

‎README.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,23 @@ export PRIME_API_KEY=...
129129
### Model Providers
130130
We currently support most major clients (OpenAI, Anthropic), as well as the router platforms (OpenRouter, Portkey). For local models, we recommend using vLLM (which interfaces with the [OpenAI client](https://github.com/alexzhang13/rlm/blob/main/rlm/clients/openai.py)). To view or add support for more clients, start by looking at [`rlm/clients/`](https://github.com/alexzhang13/rlm/tree/main/rlm/clients).
131131

132+
Applications can also inject a client factory without modifying the global backend
133+
registry. The factory receives the backend identifier and backend arguments, returns a
134+
[`BaseLM`](https://github.com/alexzhang13/rlm/blob/main/rlm/clients/base_lm.py), and is
135+
used for the root model, recursive children, explicit model overrides, and max-depth
136+
fallback calls:
137+
138+
```python
139+
from rlm import RLM
140+
141+
rlm = RLM(
142+
backend="my-runtime",
143+
backend_kwargs={"model_name": "orchestrator"},
144+
client_factory=lambda backend, kwargs: MyClient(**kwargs),
145+
max_depth=3,
146+
)
147+
```
148+
132149
## Training
133150
We provide a simple RL training harness for training RLMs used in this repo (specifically the `local` REPL). The implementation uses no sandboxes for simplicity and slots easily your use case, but an ideal setup would use sandboxes for safety. Training logic is isolated to the [`training/`](https://github.com/alexzhang13/rlm/tree/main/training) folder, which exposes `rlm.RLM` as a [`verifiers`](https://github.com/willccbb/verifiers) `Environment` and plugs straight into [`prime-rl`](https://github.com/PrimeIntellect-ai/prime-rl). See the [training README](https://github.com/alexzhang13/rlm/tree/main/training#readme) for the launch command. The harness uses subprocess-isolated local REPL execution (no cloud sandboxes), matching the `local` environment above.
134151

‎rlm/clients/__init__.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
1+
from collections.abc import Callable
12
from typing import Any
23

34
from dotenv import load_dotenv
45

56
from rlm.clients.base_lm import BaseLM
67
from rlm.core.types import ClientBackend
78

9+
ClientFactory = Callable[[str, dict[str, Any]], BaseLM]
10+
811
load_dotenv()
912

1013

1114
def get_client(
12-
backend: ClientBackend,
15+
backend: ClientBackend | str,
1316
backend_kwargs: dict[str, Any],
1417
) -> BaseLM:
1518
"""

‎rlm/core/rlm.py‎

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from contextlib import contextmanager
44
from typing import Any
55

6-
from rlm.clients import BaseLM, get_client
6+
from rlm.clients import BaseLM, ClientFactory, get_client
77
from rlm.core.lm_handler import LMHandler
88
from rlm.core.types import (
99
ClientBackend,
@@ -48,7 +48,7 @@ class RLM:
4848

4949
def __init__(
5050
self,
51-
backend: ClientBackend = "openai",
51+
backend: ClientBackend | str = "openai",
5252
backend_kwargs: dict[str, Any] | None = None,
5353
environment: EnvironmentType = "local",
5454
environment_kwargs: dict[str, Any] | None = None,
@@ -60,7 +60,7 @@ def __init__(
6060
max_tokens: int | None = None,
6161
max_errors: int | None = None,
6262
custom_system_prompt: str | None = None,
63-
other_backends: list[ClientBackend] | None = None,
63+
other_backends: list[ClientBackend | str] | None = None,
6464
other_backend_kwargs: list[dict[str, Any]] | None = None,
6565
logger: RLMLogger | None = None,
6666
verbose: bool = False,
@@ -78,10 +78,12 @@ def __init__(
7878
sub_sampling_args: dict[str, Any] | None = None,
7979
orchestrator: bool = True,
8080
user_prologue: str | None = None,
81+
client_factory: ClientFactory | None = None,
8182
):
8283
"""
8384
Args:
84-
backend: The backend to use for the RLM.
85+
backend: The backend identifier to use for the RLM. Custom identifiers are
86+
allowed when ``client_factory`` is supplied.
8587
backend_kwargs: The kwargs to pass to the backend.
8688
environment: The environment to use for the RLM.
8789
environment_kwargs: The kwargs to pass to the environment.
@@ -112,6 +114,9 @@ def __init__(
112114
on_subcall_complete: Callback fired when a child RLM completes. Args: (depth, model, duration, error_or_none).
113115
on_iteration_start: Callback fired when an iteration starts. Args: (depth, iteration_num).
114116
on_iteration_complete: Callback fired when an iteration completes. Args: (depth, iteration_num, duration).
117+
client_factory: Optional backend client constructor. It receives the selected
118+
backend and backend kwargs and must return a ``BaseLM``. The same factory
119+
is propagated to recursive children and max-depth fallback calls.
115120
"""
116121
# Sampling args plumbed into backend_kwargs / other_backend_kwargs
117122
# before the clients are constructed, so they reach the chat-completions
@@ -154,6 +159,7 @@ def __init__(
154159

155160
self.other_backends = other_backends
156161
self.other_backend_kwargs = other_backend_kwargs
162+
self.client_factory = client_factory or get_client
157163

158164
# Custom tools: functions available in the REPL environment
159165
self.custom_tools = custom_tools
@@ -231,12 +237,14 @@ def _spawn_completion_context(self, prompt: str | dict[str, Any]):
231237
When persistent=False (default), creates fresh environment each call.
232238
"""
233239
# Create client and wrap in handler
234-
client: BaseLM = get_client(self.backend, self.backend_kwargs)
240+
client: BaseLM = self.client_factory(self.backend, self.backend_kwargs)
235241

236242
# Create other_backend_client if provided (for depth=1 routing)
237243
other_backend_client: BaseLM | None = None
238244
if self.other_backends and self.other_backend_kwargs:
239-
other_backend_client = get_client(self.other_backends[0], self.other_backend_kwargs[0])
245+
other_backend_client = self.client_factory(
246+
self.other_backends[0], self.other_backend_kwargs[0]
247+
)
240248

241249
lm_handler = LMHandler(client, other_backend_client=other_backend_client)
242250

@@ -250,7 +258,7 @@ def _spawn_completion_context(self, prompt: str | dict[str, Any]):
250258
self.other_backend_kwargs[1:],
251259
strict=True,
252260
):
253-
other_client: BaseLM = get_client(backend, kwargs)
261+
other_client: BaseLM = self.client_factory(backend, kwargs)
254262
lm_handler.register_client(other_client.model_name, other_client)
255263

256264
lm_handler.start()
@@ -699,7 +707,7 @@ def _fallback_answer(self, message: str | dict[str, Any]) -> str:
699707
"""
700708
Fallback behavior if the RLM is actually at max depth, and should be treated as an LM.
701709
"""
702-
client: BaseLM = get_client(self.backend, self.backend_kwargs)
710+
client: BaseLM = self.client_factory(self.backend, self.backend_kwargs)
703711
response = client.completion(message)
704712
return response
705713

@@ -734,9 +742,9 @@ def _subcall(self, prompt: str, model: str | None = None) -> RLMChatCompletion:
734742
if next_depth >= self.max_depth:
735743
# Use other_backend if available, otherwise use main backend
736744
if self.other_backends and self.other_backend_kwargs:
737-
client = get_client(self.other_backends[0], self.other_backend_kwargs[0])
745+
client = self.client_factory(self.other_backends[0], self.other_backend_kwargs[0])
738746
else:
739-
client = get_client(self.backend, child_backend_kwargs or {})
747+
client = self.client_factory(self.backend, child_backend_kwargs or {})
740748
root_model = model or client.model_name
741749
start_time = time.perf_counter()
742750
try:
@@ -831,6 +839,7 @@ def _subcall(self, prompt: str, model: str | None = None) -> RLMChatCompletion:
831839
# Propagate callbacks to children for nested tracking
832840
on_subcall_start=self.on_subcall_start,
833841
on_subcall_complete=self.on_subcall_complete,
842+
client_factory=self.client_factory,
834843
)
835844
try:
836845
result = child.completion(prompt, root_prompt=None)

‎tests/test_subcall.py‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import rlm.core.rlm as rlm_module
1414
from rlm import RLM
1515
from rlm.core.types import ModelUsageSummary, UsageSummary
16+
from tests.mock_lm import MockLM
1617

1718

1819
def create_mock_lm(responses: list[str], model_name: str = "mock-model") -> Mock:
@@ -36,6 +37,109 @@ def final(content: str) -> str:
3637
return f"```repl\nanswer['content'] = {content!r}\nanswer['ready'] = True\n```"
3738

3839

40+
class TestClientFactoryInjection:
41+
def test_factory_builds_root_client(self):
42+
calls = []
43+
44+
def client_factory(backend, backend_kwargs):
45+
calls.append((backend, dict(backend_kwargs)))
46+
return MockLM(
47+
model_name=backend_kwargs["model_name"],
48+
responses=[final("root answer")],
49+
)
50+
51+
rlm = RLM(
52+
backend="custom-runtime",
53+
backend_kwargs={"model_name": "root-model"},
54+
client_factory=client_factory,
55+
)
56+
57+
result = rlm.completion("root prompt")
58+
59+
assert result.response == "root answer"
60+
assert calls == [("custom-runtime", {"model_name": "root-model"})]
61+
62+
def test_factory_propagates_to_recursive_child_with_model_override(self):
63+
calls = []
64+
65+
def client_factory(backend, backend_kwargs):
66+
calls.append((backend, dict(backend_kwargs)))
67+
return MockLM(
68+
model_name=backend_kwargs["model_name"],
69+
responses=[final("child answer")],
70+
)
71+
72+
parent = RLM(
73+
backend="openai",
74+
backend_kwargs={"model_name": "root-model"},
75+
client_factory=client_factory,
76+
max_depth=3,
77+
)
78+
79+
result = parent._subcall("review this", model="review-model")
80+
81+
assert result.response == "child answer"
82+
assert calls == [("openai", {"model_name": "review-model"})]
83+
84+
def test_factory_is_used_by_max_depth_plain_lm_fallback(self):
85+
calls = []
86+
87+
def client_factory(backend, backend_kwargs):
88+
calls.append((backend, dict(backend_kwargs)))
89+
return MockLM(
90+
model_name=backend_kwargs["model_name"],
91+
responses=["plain answer"],
92+
)
93+
94+
parent = RLM(
95+
backend="openai",
96+
backend_kwargs={"model_name": "root-model"},
97+
client_factory=client_factory,
98+
max_depth=1,
99+
)
100+
101+
result = parent._subcall("review this", model="review-model")
102+
103+
assert result.response == "plain answer"
104+
assert result.root_model == "review-model"
105+
assert calls == [("openai", {"model_name": "review-model"})]
106+
107+
def test_rlm_query_uses_factory_for_a_distinct_recursive_child(self):
108+
calls = []
109+
110+
def client_factory(backend, backend_kwargs):
111+
model = backend_kwargs["model_name"]
112+
calls.append((backend, dict(backend_kwargs)))
113+
if model == "root-model":
114+
return MockLM(
115+
model_name=model,
116+
responses=[
117+
"```repl\nreview = rlm_query('review this', model='review-model')\nprint(review)\n```",
118+
final("root accepted child review"),
119+
],
120+
)
121+
return MockLM(
122+
model_name=model,
123+
responses=[final("child review complete")],
124+
)
125+
126+
rlm = RLM(
127+
backend="openai",
128+
backend_kwargs={"model_name": "root-model"},
129+
client_factory=client_factory,
130+
max_depth=3,
131+
max_iterations=3,
132+
)
133+
134+
result = rlm.completion("root prompt")
135+
136+
assert result.response == "root accepted child review"
137+
assert calls == [
138+
("openai", {"model_name": "root-model"}),
139+
("openai", {"model_name": "review-model"}),
140+
]
141+
142+
39143
class TestSubcallTimeoutPropagation:
40144
"""Tests for max_timeout propagation to child RLM."""
41145

0 commit comments

Comments
 (0)