Skip to content

Commit 5cf16d1

Browse files
committed
Implement token limit feature with cost protection and logging
1 parent bdd9c13 commit 5cf16d1

10 files changed

Lines changed: 831 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,19 @@ You can try out RLMs quickly by installing from PyPi:
4141
pip install rlms
4242
```
4343

44+
> [!IMPORTANT]
45+
> **Token Limit & Cost Protection**: RLM has a **default limit of 1,000,000 tokens per session** to prevent unexpectedly large bills. This costs approximately:
46+
> - GPT-3.5 Turbo: ~$1 per session
47+
> - GPT-4o: ~$6 per session
48+
> - GPT-4 Turbo: ~$20 per session
49+
> - Claude Opus: ~$45 per session
50+
>
51+
> To change this limit:
52+
> ```python
53+
> rlm = RLM(max_tokens=500_000) # Reduce to 500k tokens
54+
> rlm = RLM(max_tokens=None) # Remove limit (use with caution!)
55+
> ```
56+
4457
The default RLM client uses a REPL environment that runs on the host process through Python `exec` calls. It uses the same virtual environment as the host process (i.e. it will have access to the same dependencies), but with some limitations in its available global modules. As an example, we can call RLM completions using GPT-5-nano:
4558
```python
4659
from rlm import RLM
@@ -54,6 +67,11 @@ rlm = RLM(
5467
print(rlm.completion("Print me the first 100 powers of two, each on a newline.").response)
5568
```
5669
70+
**Understanding Your Token Budget:**
71+
- Each session (one `completion()` call) is limited to 1M tokens by default
72+
- You can check token usage: `result.usage_summary.to_dict()`
73+
- Adjust the limit with `max_tokens` parameter (see [Token Limits](#important-token-limit--cost-protection) above)
74+
5775
<details>
5876
<summary><b>Manual Setup</b></summary>
5977

‎docs/api/rlm.md‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ RLM(
4545
depth: int = 0,
4646
max_depth: int = 1,
4747
max_iterations: int = 30,
48+
max_tokens: int | None = None,
4849
custom_system_prompt: str | None = None,
4950
other_backends: list[str] | None = None,
5051
other_backend_kwargs: list[dict] | None = None,
@@ -187,6 +188,83 @@ rlm = RLM(
187188

188189
---
189190

191+
#### `max_tokens`
192+
{: .no_toc }
193+
194+
**Type:** `int | None`
195+
**Default:** `1_000_000` (1 million tokens)
196+
197+
Maximum total tokens (input + output combined) allowed per `completion()` session. When this limit is reached or exceeded, the session ends early to stay within the token budget.
198+
199+
{: .warning }
200+
> **This is a cost protection feature!** The default limit prevents unexpectedly large API bills.
201+
202+
**Cost implications of 1M tokens:**
203+
204+
| Model | Cost for 1M Tokens |
205+
|:------|:-------------------|
206+
| GPT-3.5 Turbo | ~$1 |
207+
| GPT-4o | ~$6 |
208+
| GPT-4o-mini | ~$0.30 |
209+
| GPT-4 Turbo | ~$20 |
210+
| Claude Sonnet 4 | ~$9 |
211+
| Claude Opus | ~$45 |
212+
213+
**Token counting:**
214+
- Includes all LM calls (main model + sub-LM calls via `llm_query()`)
215+
- Tracks cumulative usage across all iterations in a single session
216+
- Resets for each new `completion()` call (even in persistent mode)
217+
218+
**Examples:**
219+
220+
```python
221+
# Use default 1M token limit (recommended for most use cases)
222+
rlm = RLM(
223+
backend="openai",
224+
backend_kwargs={"model_name": "gpt-4o"},
225+
# max_tokens=1_000_000 is implicit
226+
)
227+
228+
# Reduce limit for stricter budget control
229+
rlm = RLM(
230+
backend="openai",
231+
backend_kwargs={"model_name": "gpt-4o"},
232+
max_tokens=100_000, # Limit to ~$0.60 per session
233+
)
234+
235+
# Increase limit for complex, expensive tasks
236+
rlm = RLM(
237+
backend="openai",
238+
backend_kwargs={"model_name": "gpt-4o"},
239+
max_tokens=5_000_000, # Allow up to ~$30 per session
240+
)
241+
242+
# Remove limit entirely (not recommended - use with caution!)
243+
rlm = RLM(
244+
backend="openai",
245+
backend_kwargs={"model_name": "gpt-4o"},
246+
max_tokens=None, # Unlimited - can result in large bills!
247+
)
248+
249+
result = rlm.completion("Analyze this dataset...")
250+
# If limit is hit:
251+
# result.response == "Session ended: Token limit exceeded. Used 1,000,234 tokens..."
252+
```
253+
254+
**When the limit is exceeded:**
255+
- The session stops before the next iteration
256+
- A clear message is returned in `result.response`
257+
- The event is logged (if a logger is configured)
258+
- Usage summary and execution time are still populated
259+
260+
**Use cases:**
261+
- **Budget control**: Prevent runaway costs on complex tasks (default behavior)
262+
- **Cost management**: Set organization-wide limits in production
263+
- **Development**: Use lower limits during testing/debugging
264+
- **Production**: Increase limits for known expensive workflows
265+
266+
---
267+
190268
#### `custom_system_prompt`
191269
{: .no_toc }
192270

@@ -446,6 +524,7 @@ rlm = RLM(
446524

447525
# Behavior
448526
max_iterations=40,
527+
max_tokens=100_000, # Budget control
449528
max_depth=1,
450529

451530
# Debugging

‎docs/getting-started.md‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,60 @@ docker --version
6262

6363
---
6464

65+
## Understanding Token Limits & Costs
66+
67+
{: .warning }
68+
> **IMPORTANT: Default Token Limit**
69+
> RLM has a built-in safety limit of **1,000,000 tokens per session** to prevent unexpectedly large API bills.
70+
71+
### What does 1M tokens cost?
72+
73+
The cost varies by model:
74+
75+
| Model | Approximate Cost for 1M Tokens |
76+
|:------|:-------------------------------|
77+
| GPT-3.5 Turbo | ~$1 |
78+
| GPT-4o | ~$6 |
79+
| GPT-4o-mini | ~$0.30 |
80+
| GPT-4 Turbo | ~$20 |
81+
| Claude Sonnet 4 | ~$9 |
82+
| Claude Opus | ~$45 |
83+
84+
### How to adjust the limit
85+
86+
```python
87+
# Reduce limit for tighter budget control
88+
rlm = RLM(
89+
backend="openai",
90+
backend_kwargs={"model_name": "gpt-4o"},
91+
max_tokens=100_000, # Limit to ~$0.60 per session
92+
)
93+
94+
# Increase limit for complex tasks
95+
rlm = RLM(
96+
backend="openai",
97+
backend_kwargs={"model_name": "gpt-4o"},
98+
max_tokens=5_000_000, # Allow up to ~$30 per session
99+
)
100+
101+
# Remove limit entirely (use with caution!)
102+
rlm = RLM(
103+
backend="openai",
104+
backend_kwargs={"model_name": "gpt-4o"},
105+
max_tokens=None, # Unlimited - monitor your usage!
106+
)
107+
```
108+
109+
### What happens when the limit is reached?
110+
111+
When your session hits the token limit:
112+
1. RLM stops before the next iteration
113+
2. Returns a clear message: `"Session ended: Token limit exceeded. Used X tokens (limit: Y)..."`
114+
3. Logs the event (if logging is enabled)
115+
4. You still receive usage statistics and timing information
116+
117+
---
118+
65119
## Your First RLM Call
66120

67121
### Step 1: Set Up API Keys
@@ -133,6 +187,7 @@ This will display:
133187
| `environment_kwargs` | `dict` | `None` | Environment configuration |
134188
| `max_depth` | `int` | `1` | Maximum recursion depth |
135189
| `max_iterations` | `int` | `30` | Max REPL iterations per call |
190+
| `max_tokens` | `int` | `1_000_000` | **Max total tokens per session** (see [Token Limits](#understanding-token-limits--costs)) |
136191
| `custom_system_prompt` | `str` | `None` | Override default system prompt |
137192
| `other_backends` | `list` | `None` | Additional backends for sub-calls |
138193
| `other_backend_kwargs` | `list` | `None` | Configs for additional backends |

‎rlm/core/rlm.py‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
RLMChatCompletion,
1313
RLMIteration,
1414
RLMMetadata,
15+
UsageSummary,
1516
)
1617
from rlm.environments import BaseEnv, SupportsPersistence, get_environment
1718
from rlm.logger import RLMLogger, VerbosePrinter
@@ -46,6 +47,7 @@ def __init__(
4647
depth: int = 0,
4748
max_depth: int = 1,
4849
max_iterations: int = 30,
50+
max_tokens: int | None = 1_000_000,
4951
custom_system_prompt: str | None = None,
5052
other_backends: list[ClientBackend] | None = None,
5153
other_backend_kwargs: list[dict[str, Any]] | None = None,
@@ -62,6 +64,12 @@ def __init__(
6264
depth: The current depth of the RLM (0-indexed).
6365
max_depth: The maximum depth of the RLM. Currently, only depth 1 is supported.
6466
max_iterations: The maximum number of iterations of the RLM.
67+
max_tokens: The maximum total tokens (input + output) allowed per completion() session.
68+
DEFAULT: 1,000,000 tokens to prevent unexpectedly large API bills.
69+
COST ESTIMATE: 1M tokens costs ~$1-45 depending on your model:
70+
- GPT-3.5 Turbo: ~$1 | GPT-4o: ~$6 | GPT-4 Turbo: ~$20 | Claude Opus: ~$45
71+
Set to None for unlimited tokens (use with caution - can result in expensive bills).
72+
Set to a smaller value (e.g., 100_000) for tighter budget control.
6573
custom_system_prompt: The custom system prompt to use for the RLM.
6674
other_backends: A list of other client backends that the environments can use to make sub-calls.
6775
other_backend_kwargs: The kwargs to pass to the other client backends (ordered to match other_backends).
@@ -90,6 +98,7 @@ def __init__(
9098
self.depth = depth
9199
self.max_depth = max_depth
92100
self.max_iterations = max_iterations
101+
self.max_tokens = max_tokens
93102
self.system_prompt = custom_system_prompt if custom_system_prompt else RLM_SYSTEM_PROMPT
94103
self.logger = logger
95104
self.verbose = VerbosePrinter(enabled=verbose)
@@ -110,6 +119,7 @@ def __init__(
110119
else "unknown",
111120
max_depth=max_depth,
112121
max_iterations=max_iterations,
122+
max_tokens=max_tokens,
113123
backend=backend,
114124
backend_kwargs=filter_sensitive_keys(backend_kwargs) if backend_kwargs else {},
115125
environment_type=environment,
@@ -215,6 +225,16 @@ def completion(
215225
message_history = self._setup_prompt(prompt)
216226

217227
for i in range(self.max_iterations):
228+
# Check token limit before starting next iteration
229+
if self.max_tokens is not None:
230+
current_usage = lm_handler.get_usage_summary()
231+
tokens_used = self._calculate_total_tokens(current_usage)
232+
if tokens_used >= self.max_tokens:
233+
# Store message history in persistent environment
234+
if self.persistent and isinstance(environment, SupportsPersistence):
235+
environment.add_history(message_history)
236+
return self._token_limit_exceeded_answer(i, tokens_used, time_start, lm_handler)
237+
218238
# Current prompt = message history + additional prompt suffix
219239
context_count = (
220240
environment.get_context_count()
@@ -354,6 +374,62 @@ def _fallback_answer(self, message: str | dict[str, Any]) -> str:
354374
response = client.completion(message)
355375
return response
356376

377+
def _calculate_total_tokens(self, usage: UsageSummary) -> int:
378+
"""Calculate total tokens (input + output) from usage summary."""
379+
total = 0
380+
for model_usage in usage.model_usage_summaries.values():
381+
total += model_usage.total_input_tokens + model_usage.total_output_tokens
382+
return total
383+
384+
def _token_limit_exceeded_answer(
385+
self,
386+
iteration: int,
387+
tokens_used: int,
388+
time_start: float,
389+
lm_handler: LMHandler,
390+
) -> RLMChatCompletion:
391+
"""
392+
Handle early termination when token limit is exceeded.
393+
Returns a completion indicating the session ended due to token limit.
394+
"""
395+
time_end = time.perf_counter()
396+
usage = lm_handler.get_usage_summary()
397+
398+
# Log the limit hit event
399+
if self.logger:
400+
self.logger.log_limit_hit(
401+
limit_type="token_limit",
402+
max_value=self.max_tokens,
403+
current_value=tokens_used,
404+
iteration=iteration,
405+
)
406+
407+
# Print verbose output
408+
self.verbose.print_token_limit_hit(
409+
max_tokens=self.max_tokens,
410+
tokens_used=tokens_used,
411+
iteration=iteration,
412+
)
413+
self.verbose.print_summary(
414+
iteration, time_end - time_start, usage.to_dict(), stopped_reason="token_limit"
415+
)
416+
417+
response_message = (
418+
f"Session ended: Token limit exceeded. "
419+
f"Used {tokens_used:,} tokens (limit: {self.max_tokens:,}). "
420+
f"Completed {iteration} iteration(s) before reaching limit."
421+
)
422+
423+
return RLMChatCompletion(
424+
root_model=self.backend_kwargs.get("model_name", "unknown")
425+
if self.backend_kwargs
426+
else "unknown",
427+
prompt="",
428+
response=response_message,
429+
usage_summary=usage,
430+
execution_time=time_end - time_start,
431+
)
432+
357433
def _validate_persistent_environment_support(self) -> None:
358434
"""
359435
Validate that the configured environment type supports persistent mode.

‎rlm/core/types.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ class RLMMetadata:
192192
root_model: str
193193
max_depth: int
194194
max_iterations: int
195+
max_tokens: int | None
195196
backend: str
196197
backend_kwargs: dict[str, Any]
197198
environment_type: str
@@ -203,6 +204,7 @@ def to_dict(self):
203204
"root_model": self.root_model,
204205
"max_depth": self.max_depth,
205206
"max_iterations": self.max_iterations,
207+
"max_tokens": self.max_tokens,
206208
"backend": self.backend,
207209
"backend_kwargs": {k: _serialize_value(v) for k, v in self.backend_kwargs.items()},
208210
"environment_type": self.environment_type,

‎rlm/logger/rlm_logger.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,23 @@ def log(self, iteration: RLMIteration):
5858
json.dump(entry, f)
5959
f.write("\n")
6060

61+
def log_limit_hit(
62+
self, limit_type: str, max_value: int, current_value: int, iteration: int
63+
):
64+
"""Log a limit hit event (e.g., token limit exceeded)."""
65+
entry = {
66+
"type": "limit_hit",
67+
"limit_type": limit_type,
68+
"max_value": max_value,
69+
"current_value": current_value,
70+
"iteration": iteration,
71+
"timestamp": datetime.now().isoformat(),
72+
}
73+
74+
with open(self.log_file_path, "a") as f:
75+
json.dump(entry, f)
76+
f.write("\n")
77+
6178
@property
6279
def iteration_count(self) -> int:
6380
return self._iteration_count

0 commit comments

Comments
 (0)