Skip to content

Commit 4eba6ec

Browse files
committed
Merge remote-tracking branch 'refs/remotes/origin/main'
2 parents c81e982 + 5995713 commit 4eba6ec

8 files changed

Lines changed: 463 additions & 27 deletions

File tree

‎rlm/clients/openai.py‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,17 @@
1414
DEFAULT_OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
1515
DEFAULT_OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
1616
DEFAULT_VERCEL_API_KEY = os.getenv("AI_GATEWAY_API_KEY")
17+
DEFAULT_PRIME_API_KEY = os.getenv("PRIME_API_KEY")
1718
DEFAULT_PRIME_INTELLECT_BASE_URL = "https://api.pinference.ai/api/v1/"
1819

1920

2021
class OpenAIClient(BaseLM):
2122
"""
2223
LM Client for running models with the OpenAI API. Works with vLLM as well.
24+
25+
Any additional keyword arguments (e.g. default_headers, default_query, max_retries)
26+
are passed through to the underlying openai.OpenAI and openai.AsyncOpenAI constructors.
27+
Only model_name is excluded, since it is not a client constructor argument.
2328
"""
2429

2530
def __init__(
@@ -38,12 +43,19 @@ def __init__(
3843
api_key = DEFAULT_OPENROUTER_API_KEY
3944
elif base_url == "https://ai-gateway.vercel.sh/v1":
4045
api_key = DEFAULT_VERCEL_API_KEY
41-
42-
# For vLLM, set base_url to local vLLM server address.
43-
self.client = openai.OpenAI(api_key=api_key, base_url=base_url, timeout=self.timeout)
44-
self.async_client = openai.AsyncOpenAI(
45-
api_key=api_key, base_url=base_url, timeout=self.timeout
46-
)
46+
elif base_url == DEFAULT_PRIME_INTELLECT_BASE_URL:
47+
api_key = DEFAULT_PRIME_API_KEY
48+
49+
# Pass through arbitrary kwargs to the OpenAI client (e.g. default_headers, default_query, max_retries).
50+
# Exclude model_name since it is not an OpenAI client constructor argument.
51+
client_kwargs = {
52+
"api_key": api_key,
53+
"base_url": base_url,
54+
"timeout": self.timeout,
55+
**{k: v for k, v in self.kwargs.items() if k != "model_name"},
56+
}
57+
self.client = openai.OpenAI(**client_kwargs)
58+
self.async_client = openai.AsyncOpenAI(**client_kwargs)
4759
self.model_name = model_name
4860
self.base_url = base_url # Track for cost extraction
4961

‎rlm/core/lm_handler.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,14 @@ def _handle_batched(self, request: LMRequest, handler: "LMHandler") -> LMRespons
8585

8686
start_time = time.perf_counter()
8787

88+
sem = asyncio.Semaphore(handler.batch_max_concurrent)
89+
90+
async def run_one(prompt: str):
91+
async with sem:
92+
return await client.acompletion(prompt)
93+
8894
async def run_all():
89-
tasks = [client.acompletion(prompt) for prompt in request.prompts]
95+
tasks = [run_one(prompt) for prompt in request.prompts]
9096
return await asyncio.gather(*tasks)
9197

9298
results = asyncio.run(run_all())
@@ -132,6 +138,7 @@ def __init__(
132138
host: str = "127.0.0.1",
133139
port: int = 0, # auto-assign available port
134140
other_backend_client: BaseLM | None = None,
141+
batch_max_concurrent: int = 16,
135142
):
136143
self.default_client = client
137144
self.other_backend_client = other_backend_client
@@ -140,6 +147,7 @@ def __init__(
140147
self._server: ThreadingLMServer | None = None
141148
self._thread: Thread | None = None
142149
self._port = port
150+
self.batch_max_concurrent = batch_max_concurrent
143151

144152
self.register_client(client.model_name, client)
145153

‎rlm/core/rlm.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ def __init__(
7070
custom_sub_tools: dict[str, Any] | None = None,
7171
compaction: bool = False,
7272
compaction_threshold_pct: float = 0.85,
73+
max_concurrent_subcalls: int = 4,
7374
on_subcall_start: Callable[[int, str, str], None] | None = None,
7475
on_subcall_complete: Callable[[int, str, float, str | None], None] | None = None,
7576
on_iteration_start: Callable[[int, int], None] | None = None,
@@ -102,6 +103,8 @@ def __init__(
102103
when root context reaches compaction_threshold_pct of the model's context limit.
103104
compaction_threshold_pct: When compaction is on, trigger summarization when root
104105
message token count reaches this fraction of the model context limit (default 0.85).
106+
max_concurrent_subcalls: Maximum number of parallel threads for rlm_query_batched subcalls.
107+
Each child RLM runs in its own thread. Default 4.
105108
on_subcall_start: Callback fired when a child RLM starts. Args: (depth, model, prompt_preview).
106109
on_subcall_complete: Callback fired when a child RLM completes. Args: (depth, model, duration, error_or_none).
107110
on_iteration_start: Callback fired when an iteration starts. Args: (depth, iteration_num).
@@ -132,6 +135,7 @@ def __init__(
132135

133136
self.compaction = compaction
134137
self.compaction_threshold_pct = compaction_threshold_pct
138+
self.max_concurrent_subcalls = max_concurrent_subcalls
135139

136140
self.depth = depth
137141
self.max_depth = max_depth
@@ -238,6 +242,7 @@ def _spawn_completion_context(self, prompt: str | dict[str, Any]):
238242
env_kwargs["custom_sub_tools"] = self.custom_sub_tools
239243
if self.compaction and self.environment_type == "local":
240244
env_kwargs["compaction"] = True
245+
env_kwargs["max_concurrent_subcalls"] = self.max_concurrent_subcalls
241246
environment: BaseEnv = get_environment(self.environment_type, env_kwargs)
242247

243248
if self.persistent:
@@ -765,6 +770,8 @@ def _subcall(self, prompt: str, model: str | None = None) -> RLMChatCompletion:
765770
# Propagate custom tools to children (sub_tools become the child's tools)
766771
custom_tools=self.custom_sub_tools,
767772
custom_sub_tools=self.custom_sub_tools,
773+
# Propagate concurrency settings to children
774+
max_concurrent_subcalls=self.max_concurrent_subcalls,
768775
# Propagate callbacks to children for nested tracking
769776
on_subcall_start=self.on_subcall_start,
770777
on_subcall_complete=self.on_subcall_complete,

‎rlm/environments/base_env.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,9 +212,12 @@ class BaseEnv(ABC):
212212
}
213213
"""
214214

215-
def __init__(self, persistent: bool = False, depth: int = 1, **kwargs):
215+
def __init__(
216+
self, persistent: bool = False, depth: int = 1, max_concurrent_subcalls: int = 4, **kwargs
217+
):
216218
self.persistent = persistent
217219
self.depth = depth
220+
self.max_concurrent_subcalls = max_concurrent_subcalls
218221
self.kwargs = kwargs
219222

220223
@abstractmethod

‎rlm/environments/local_repl.py‎

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import time
1010
import uuid
1111
from collections.abc import Callable
12+
from concurrent.futures import ThreadPoolExecutor, as_completed
1213
from contextlib import contextmanager
1314
from typing import Any
1415

@@ -135,9 +136,15 @@ def __init__(
135136
custom_tools: dict[str, Any] | None = None,
136137
custom_sub_tools: dict[str, Any] | None = None,
137138
compaction: bool = False,
139+
max_concurrent_subcalls: int = 4,
138140
**kwargs,
139141
):
140-
super().__init__(persistent=persistent, depth=depth, **kwargs)
142+
super().__init__(
143+
persistent=persistent,
144+
depth=depth,
145+
max_concurrent_subcalls=max_concurrent_subcalls,
146+
**kwargs,
147+
)
141148

142149
self.lm_handler_address = lm_handler_address
143150
self.subcall_fn = subcall_fn # Callback for recursive RLM calls (depth > 1 support)
@@ -316,9 +323,13 @@ def _rlm_query(self, prompt: str, model: str | None = None) -> str:
316323
return self._llm_query(prompt, model)
317324

318325
def _rlm_query_batched(self, prompts: list[str], model: str | None = None) -> list[str]:
319-
"""Spawn recursive RLM sub-calls for multiple prompts.
326+
"""Spawn recursive RLM sub-calls for multiple prompts in parallel.
327+
328+
Each prompt gets its own child RLM for deeper thinking. When multiple
329+
prompts are provided, subcalls run concurrently using a thread pool
330+
(bounded by max_concurrent_subcalls) since they are independent and
331+
I/O-bound. Results are returned in the same order as input prompts.
320332
321-
Each prompt gets its own child RLM for deeper thinking.
322333
Falls back to llm_query_batched if no recursive capability is configured.
323334
324335
Args:
@@ -329,14 +340,47 @@ def _rlm_query_batched(self, prompts: list[str], model: str | None = None) -> li
329340
List of responses in the same order as input prompts.
330341
"""
331342
if self.subcall_fn is not None:
332-
results = []
333-
for prompt in prompts:
343+
# For 0 or 1 prompts, no need for thread pool overhead
344+
if len(prompts) <= 1:
345+
results = []
346+
for prompt in prompts:
347+
try:
348+
completion = self.subcall_fn(prompt, model)
349+
self._pending_llm_calls.append(completion)
350+
results.append(completion.response)
351+
except Exception as e:
352+
results.append(f"Error: RLM query failed - {e}")
353+
return results
354+
355+
# Parallel execution for multiple prompts
356+
max_workers = min(self.max_concurrent_subcalls, len(prompts))
357+
# Pre-allocate result slots to preserve ordering
358+
results: list[str] = [""] * len(prompts)
359+
completions: list[tuple[int, RLMChatCompletion]] = []
360+
lock = threading.Lock()
361+
362+
def _run_subcall(index: int, prompt: str) -> None:
334363
try:
335364
completion = self.subcall_fn(prompt, model)
336-
self._pending_llm_calls.append(completion)
337-
results.append(completion.response)
365+
with lock:
366+
completions.append((index, completion))
367+
results[index] = completion.response
338368
except Exception as e:
339-
results.append(f"Error: RLM query failed - {e}")
369+
results[index] = f"Error: RLM query failed - {e}"
370+
371+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
372+
futures = [
373+
executor.submit(_run_subcall, i, prompt) for i, prompt in enumerate(prompts)
374+
]
375+
# Wait for all futures to complete; exceptions are captured inside _run_subcall
376+
for future in as_completed(futures):
377+
future.result() # Re-raises unexpected executor errors
378+
379+
# Append completions in original prompt order for deterministic metadata
380+
completions.sort(key=lambda x: x[0])
381+
for _, completion in completions:
382+
self._pending_llm_calls.append(completion)
383+
340384
return results
341385

342386
# Fall back to plain batched LM call if no recursive capability

‎tests/mock_lm.py‎

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,61 @@
1+
"""Mock LM for testing without a real model."""
2+
3+
from collections.abc import Callable
4+
from typing import Any
5+
16
from rlm.clients.base_lm import BaseLM
27
from rlm.core.types import ModelUsageSummary, UsageSummary
38

49

510
class MockLM(BaseLM):
6-
"""Simple mock LM that echoes prompts."""
11+
"""
12+
In-memory mock LM that implements BaseLM for testing.
713
8-
def __init__(self):
9-
super().__init__(model_name="mock-model")
14+
- completion() / acompletion(): echo-style response by default, or use
15+
optional responses list / response_fn if provided.
16+
- get_usage_summary() / get_last_usage(): return usage suitable for tests.
17+
"""
1018

11-
def completion(self, prompt):
12-
return f"Mock response to: {prompt[:50]}"
19+
def __init__(
20+
self,
21+
model_name: str = "mock-model",
22+
responses: list[str] | None = None,
23+
response_fn: Callable[[str | dict[str, Any]], str] | None = None,
24+
**kwargs: Any,
25+
):
26+
super().__init__(model_name=model_name, **kwargs)
27+
self._responses = list(responses) if responses is not None else None
28+
self._response_fn = response_fn
29+
self._call_count = 0
1330

14-
async def acompletion(self, prompt):
31+
def completion(self, prompt: str | dict[str, Any]) -> str:
32+
self._call_count += 1
33+
if self._responses is not None:
34+
if not self._responses:
35+
raise IndexError("MockLM: no more responses in list")
36+
return self._responses.pop(0)
37+
if self._response_fn is not None:
38+
return self._response_fn(prompt)
39+
prompt_str = prompt if isinstance(prompt, str) else str(prompt)[:80]
40+
return f"Mock response to: {prompt_str}"
41+
42+
async def acompletion(self, prompt: str | dict[str, Any]) -> str:
1543
return self.completion(prompt)
1644

17-
def get_usage_summary(self):
45+
def get_usage_summary(self) -> UsageSummary:
1846
return UsageSummary(
1947
model_usage_summaries={
20-
"mock-model": ModelUsageSummary(
21-
total_calls=1, total_input_tokens=10, total_output_tokens=10
48+
self.model_name: ModelUsageSummary(
49+
total_calls=self._call_count,
50+
total_input_tokens=self._call_count * 10,
51+
total_output_tokens=self._call_count * 10,
2252
)
2353
}
2454
)
2555

26-
def get_last_usage(self):
27-
return self.get_usage_summary()
56+
def get_last_usage(self) -> ModelUsageSummary:
57+
return ModelUsageSummary(
58+
total_calls=1,
59+
total_input_tokens=10,
60+
total_output_tokens=10,
61+
)

‎tests/test_lm_handler.py‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Tests for LMHandler using MockLM (no real LM required)."""
2+
3+
from rlm.core.comms_utils import LMRequest, send_lm_request, send_lm_request_batched
4+
from rlm.core.lm_handler import LMHandler
5+
from tests.mock_lm import MockLM
6+
7+
8+
def test_lm_handler_single_request():
9+
"""Single prompt request returns success and echo-style content."""
10+
mock = MockLM(responses=["hello back"])
11+
with LMHandler(client=mock) as handler:
12+
request = LMRequest(prompt="hello")
13+
response = send_lm_request(handler.address, request)
14+
assert response.success
15+
assert response.chat_completion is not None
16+
assert response.chat_completion.response == "hello back"
17+
18+
19+
def test_lm_handler_batched_request():
20+
"""Batched prompts return one response per prompt in order."""
21+
responses = [f"r{i}" for i in range(5)]
22+
mock = MockLM(responses=responses)
23+
with LMHandler(client=mock, batch_max_concurrent=3) as handler:
24+
prompts = [f"prompt-{i}" for i in range(5)]
25+
result = send_lm_request_batched(handler.address, prompts)
26+
assert len(result) == 5
27+
for i, resp in enumerate(result):
28+
assert resp.success, resp.error
29+
assert resp.chat_completion is not None
30+
assert resp.chat_completion.response == f"r{i}"
31+
32+
33+
def test_lm_handler_batched_many_prompts_semaphore_cap():
34+
"""Many prompts complete successfully with semaphore limiting concurrency."""
35+
# 50 prompts, max 4 concurrent: should still all complete
36+
count = 50
37+
responses = [f"resp-{i}" for i in range(count)]
38+
mock = MockLM(responses=responses)
39+
with LMHandler(client=mock, batch_max_concurrent=4) as handler:
40+
prompts = [f"p-{i}" for i in range(count)]
41+
result = send_lm_request_batched(handler.address, prompts)
42+
assert len(result) == count
43+
for i, resp in enumerate(result):
44+
assert resp.success, (i, resp.error)
45+
assert resp.chat_completion.response == f"resp-{i}"

0 commit comments

Comments
 (0)