Skip to content

Commit 3ce2dc8

Browse files
committed
add anthropic prompt caching, cost tracking, and shared handler optimizations
- Add prefix caching support with cache_control annotations for Anthropic API - Add per-model USD cost tracking using built-in pricing table - Track cache creation/read tokens in usage summaries - Add shared LM handler for child RLMs to reduce per-subcall overhead - Update local_repl and test_rlm_query for new features - Sync uv.lock version to 0.1.1
1 parent fcd036d commit 3ce2dc8

6 files changed

Lines changed: 557 additions & 62 deletions

File tree

‎rlm/clients/anthropic.py‎

Lines changed: 106 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,33 @@
66
from rlm.clients.base_lm import BaseLM
77
from rlm.core.types import ModelUsageSummary, UsageSummary
88

9+
# Per-1M-token pricing (USD) as of 2025-06. Update as needed or use pricing_override.
10+
_ANTHROPIC_PRICING: dict[str, tuple[float, float]] = {
11+
# (input_per_1M, output_per_1M)
12+
# Claude 4 family
13+
"claude-opus-4-20250514": (15.0, 75.0),
14+
"claude-opus-4-latest": (15.0, 75.0),
15+
"claude-sonnet-4-20250514": (3.0, 15.0),
16+
"claude-sonnet-4-latest": (3.0, 15.0),
17+
# Claude 3.7
18+
"claude-3-7-sonnet-20250219": (3.0, 15.0),
19+
"claude-3-7-sonnet-latest": (3.0, 15.0),
20+
# Claude 3.5
21+
"claude-3-5-sonnet-20241022": (3.0, 15.0),
22+
"claude-3-5-sonnet-20240620": (3.0, 15.0),
23+
"claude-3-5-sonnet-latest": (3.0, 15.0),
24+
"claude-3-5-haiku-20241022": (0.80, 4.0),
25+
"claude-3-5-haiku-latest": (0.80, 4.0),
26+
# Claude 3
27+
"claude-3-opus-20240229": (15.0, 75.0),
28+
"claude-3-opus-latest": (15.0, 75.0),
29+
"claude-3-sonnet-20240229": (3.0, 15.0),
30+
"claude-3-haiku-20240307": (0.25, 1.25),
31+
}
32+
33+
_CACHE_CREATION_MULTIPLIER = 1.25
34+
_CACHE_READ_MULTIPLIER = 0.10
35+
936

1037
class AnthropicClient(BaseLM):
1138
"""
@@ -17,19 +44,26 @@ def __init__(
1744
api_key: str,
1845
model_name: str | None = None,
1946
max_tokens: int = 32768,
47+
enable_prefix_cache: bool = False,
48+
pricing_override: dict[str, tuple[float, float]] | None = None,
2049
**kwargs,
2150
):
2251
super().__init__(model_name=model_name, **kwargs)
2352
self.client = anthropic.Anthropic(api_key=api_key, timeout=self.timeout)
2453
self.async_client = anthropic.AsyncAnthropic(api_key=api_key, timeout=self.timeout)
2554
self.model_name = model_name
2655
self.max_tokens = max_tokens
56+
self.enable_prefix_cache = enable_prefix_cache
57+
self._pricing = {**_ANTHROPIC_PRICING, **(pricing_override or {})}
2758

2859
# Per-model usage tracking
2960
self.model_call_counts: dict[str, int] = defaultdict(int)
3061
self.model_input_tokens: dict[str, int] = defaultdict(int)
3162
self.model_output_tokens: dict[str, int] = defaultdict(int)
3263
self.model_total_tokens: dict[str, int] = defaultdict(int)
64+
self.model_costs: dict[str, float] = defaultdict(float)
65+
self.model_cache_creation_tokens: dict[str, int] = defaultdict(int)
66+
self.model_cache_read_tokens: dict[str, int] = defaultdict(int)
3367

3468
def completion(self, prompt: str | list[dict[str, Any]], model: str | None = None) -> str:
3569
messages, system = self._prepare_messages(prompt)
@@ -65,9 +99,13 @@ async def acompletion(
6599

66100
def _prepare_messages(
67101
self, prompt: str | list[dict[str, Any]]
68-
) -> tuple[list[dict[str, Any]], str | None]:
69-
"""Prepare messages and extract system prompt for Anthropic API."""
70-
system = None
102+
) -> tuple[list[dict[str, Any]], str | list[dict[str, Any]] | None]:
103+
"""Prepare messages and extract system prompt for Anthropic API.
104+
105+
When enable_prefix_cache is True, annotates the system prompt and the
106+
last user message with cache_control for Anthropic prompt caching.
107+
"""
108+
system: str | list[dict[str, Any]] | None = None
71109

72110
if isinstance(prompt, str):
73111
messages = [{"role": "user", "content": prompt}]
@@ -82,25 +120,84 @@ def _prepare_messages(
82120
else:
83121
raise ValueError(f"Invalid prompt type: {type(prompt)}")
84122

123+
if self.enable_prefix_cache:
124+
# Annotate system prompt with cache_control
125+
if system is not None:
126+
if isinstance(system, str):
127+
system = [
128+
{"type": "text", "text": system, "cache_control": {"type": "ephemeral"}}
129+
]
130+
elif isinstance(system, list) and system:
131+
# System is already a list of content blocks; annotate the last block
132+
last = system[-1]
133+
if isinstance(last, dict):
134+
system = system[:-1] + [{**last, "cache_control": {"type": "ephemeral"}}]
135+
136+
# Annotate last user message content with cache_control
137+
if messages:
138+
for i in range(len(messages) - 1, -1, -1):
139+
if messages[i].get("role") == "user":
140+
content = messages[i].get("content", "")
141+
if isinstance(content, str):
142+
messages[i] = {
143+
**messages[i],
144+
"content": [
145+
{
146+
"type": "text",
147+
"text": content,
148+
"cache_control": {"type": "ephemeral"},
149+
}
150+
],
151+
}
152+
break
153+
85154
return messages, system
86155

87156
def _track_cost(self, response: anthropic.types.Message, model: str):
88157
self.model_call_counts[model] += 1
89-
self.model_input_tokens[model] += response.usage.input_tokens
90-
self.model_output_tokens[model] += response.usage.output_tokens
91-
self.model_total_tokens[model] += response.usage.input_tokens + response.usage.output_tokens
158+
input_tokens = response.usage.input_tokens
159+
output_tokens = response.usage.output_tokens
160+
self.model_input_tokens[model] += input_tokens
161+
self.model_output_tokens[model] += output_tokens
162+
self.model_total_tokens[model] += input_tokens + output_tokens
163+
164+
# Cache token tracking (Anthropic extended usage fields)
165+
cache_creation = getattr(response.usage, "cache_creation_input_tokens", 0) or 0
166+
cache_read = getattr(response.usage, "cache_read_input_tokens", 0) or 0
167+
self.model_cache_creation_tokens[model] += cache_creation
168+
self.model_cache_read_tokens[model] += cache_read
92169

93170
# Track last call for handler to read
94-
self.last_prompt_tokens = response.usage.input_tokens
95-
self.last_completion_tokens = response.usage.output_tokens
171+
self.last_prompt_tokens = input_tokens
172+
self.last_completion_tokens = output_tokens
173+
174+
# Compute USD cost if pricing is known.
175+
# Anthropic usage fields decompose input tokens into three disjoint groups:
176+
# input_tokens: non-cached input tokens (charged at base rate)
177+
# cache_creation_input_tokens: tokens written to cache (1.25x base rate)
178+
# cache_read_input_tokens: tokens read from cache (0.10x base rate)
179+
self.last_cost: float | None = None
180+
pricing = self._pricing.get(model)
181+
if pricing is not None:
182+
input_price, output_price = pricing
183+
cost = (
184+
input_tokens * input_price
185+
+ output_tokens * output_price
186+
+ cache_creation * input_price * _CACHE_CREATION_MULTIPLIER
187+
+ cache_read * input_price * _CACHE_READ_MULTIPLIER
188+
) / 1_000_000
189+
self.last_cost = cost
190+
self.model_costs[model] += cost
96191

97192
def get_usage_summary(self) -> UsageSummary:
98193
model_summaries = {}
99194
for model in self.model_call_counts:
195+
cost = self.model_costs.get(model)
100196
model_summaries[model] = ModelUsageSummary(
101197
total_calls=self.model_call_counts[model],
102198
total_input_tokens=self.model_input_tokens[model],
103199
total_output_tokens=self.model_output_tokens[model],
200+
total_cost=cost if cost else None,
104201
)
105202
return UsageSummary(model_usage_summaries=model_summaries)
106203

@@ -109,4 +206,5 @@ def get_last_usage(self) -> ModelUsageSummary:
109206
total_calls=1,
110207
total_input_tokens=self.last_prompt_tokens,
111208
total_output_tokens=self.last_completion_tokens,
209+
total_cost=getattr(self, "last_cost", None),
112210
)

‎rlm/core/rlm.py‎

Lines changed: 104 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import threading
12
import time
23
from collections.abc import Callable
34
from contextlib import contextmanager
@@ -74,6 +75,9 @@ def __init__(
7475
on_subcall_complete: Callable[[int, str, float, str | None], None] | None = None,
7576
on_iteration_start: Callable[[int, int], None] | None = None,
7677
on_iteration_complete: Callable[[int, int, float], None] | None = None,
78+
# Internal: shared handler/client for child RLMs (reduces per-subcall overhead)
79+
_shared_lm_handler_address: tuple[str, int] | None = None,
80+
_shared_client: "BaseLM | None" = None,
7781
):
7882
"""
7983
Args:
@@ -152,11 +156,18 @@ def __init__(
152156

153157
# Tracking (cumulative across all calls including children)
154158
self._cumulative_cost: float = 0.0
159+
self._cumulative_cost_lock = threading.Lock()
155160
self._consecutive_errors: int = 0
156161
self._last_error: str | None = None
157162
self._best_partial_answer: str | None = None
158163
self._completion_start_time: float | None = None # Set when completion() starts
159164

165+
# Shared handler/client for child RLMs (Phase D optimization)
166+
self._shared_lm_handler_address = _shared_lm_handler_address
167+
self._shared_client = _shared_client
168+
self._active_handler_address: tuple[str, int] | None = None
169+
self._active_client: BaseLM | None = None
170+
160171
# Persistence support
161172
self.persistent = persistent
162173
self._persistent_env: SupportsPersistence | None = None
@@ -192,24 +203,44 @@ def _spawn_completion_context(self, prompt: str | dict[str, Any]):
192203
193204
When persistent=True, the environment is reused across calls.
194205
When persistent=False (default), creates fresh environment each call.
195-
"""
196-
# Create client and wrap in handler
197-
client: BaseLM = get_client(self.backend, self.backend_kwargs)
198206
199-
# Create other_backend_client if provided (for depth=1 routing)
200-
other_backend_client: BaseLM | None = None
201-
if self.other_backends and self.other_backend_kwargs:
202-
other_backend_client = get_client(self.other_backends[0], self.other_backend_kwargs[0])
207+
When _shared_lm_handler_address is set (child RLM reusing parent's handler),
208+
skips creating a new LMHandler/TCP server and creates a proxy instead.
209+
"""
210+
owns_handler = False
211+
212+
if self._shared_lm_handler_address is not None and self._shared_client is not None:
213+
# Reuse parent's handler — create a lightweight proxy
214+
lm_handler = _SharedLMHandlerProxy(self._shared_client, self._shared_lm_handler_address)
215+
# Active handler info for children to reuse
216+
self._active_handler_address = self._shared_lm_handler_address
217+
self._active_client = self._shared_client
218+
else:
219+
# Create client and wrap in handler
220+
client: BaseLM = get_client(self.backend, self.backend_kwargs)
203221

204-
lm_handler = LMHandler(client, other_backend_client=other_backend_client)
222+
# Create other_backend_client if provided (for depth=1 routing)
223+
other_backend_client: BaseLM | None = None
224+
if self.other_backends and self.other_backend_kwargs:
225+
other_backend_client = get_client(
226+
self.other_backends[0], self.other_backend_kwargs[0]
227+
)
205228

206-
# Register other clients to be available as sub-call options (by model name)
207-
if self.other_backends and self.other_backend_kwargs:
208-
for backend, kwargs in zip(self.other_backends, self.other_backend_kwargs, strict=True):
209-
other_client: BaseLM = get_client(backend, kwargs)
210-
lm_handler.register_client(other_client.model_name, other_client)
229+
lm_handler = LMHandler(client, other_backend_client=other_backend_client)
211230

212-
lm_handler.start()
231+
# Register other clients to be available as sub-call options (by model name)
232+
if self.other_backends and self.other_backend_kwargs:
233+
for backend, kwargs in zip(
234+
self.other_backends, self.other_backend_kwargs, strict=True
235+
):
236+
other_client: BaseLM = get_client(backend, kwargs)
237+
lm_handler.register_client(other_client.model_name, other_client)
238+
239+
lm_handler.start()
240+
owns_handler = True
241+
# Active handler info for children to reuse
242+
self._active_handler_address = (lm_handler.host, lm_handler.port)
243+
self._active_client = client
213244

214245
# Environment: reuse if persistent, otherwise create fresh
215246
if self.persistent and self._persistent_env is not None:
@@ -221,11 +252,12 @@ def _spawn_completion_context(self, prompt: str | dict[str, Any]):
221252
f"implement required methods (update_handler_address, add_context, get_context_count). "
222253
f"This should have been caught at initialization."
223254
)
224-
environment.update_handler_address((lm_handler.host, lm_handler.port))
255+
handler_addr = self._active_handler_address
256+
environment.update_handler_address(handler_addr)
225257
environment.add_context(prompt)
226258
else:
227259
env_kwargs = self.environment_kwargs.copy()
228-
env_kwargs["lm_handler_address"] = (lm_handler.host, lm_handler.port)
260+
env_kwargs["lm_handler_address"] = self._active_handler_address
229261
env_kwargs["context_payload"] = prompt
230262
env_kwargs["depth"] = self.depth + 1 # Environment depth is RLM depth + 1
231263
# For local environment with max_depth > 1, pass subcall callback for recursive RLM calls
@@ -246,7 +278,10 @@ def _spawn_completion_context(self, prompt: str | dict[str, Any]):
246278
try:
247279
yield lm_handler, environment
248280
finally:
249-
lm_handler.stop()
281+
self._active_handler_address = None
282+
self._active_client = None
283+
if owns_handler:
284+
lm_handler.stop()
250285
if not self.persistent and hasattr(environment, "cleanup"):
251286
environment.cleanup()
252287

@@ -491,7 +526,8 @@ def _check_iteration_limits(
491526
if self.max_budget is not None:
492527
current_usage = lm_handler.get_usage_summary()
493528
current_cost = current_usage.total_cost or 0.0
494-
self._cumulative_cost = current_cost
529+
with self._cumulative_cost_lock:
530+
self._cumulative_cost = current_cost
495531
if self._cumulative_cost > self.max_budget:
496532
self.verbose.print_budget_exceeded(self._cumulative_cost, self.max_budget)
497533
raise BudgetExceededError(
@@ -703,7 +739,8 @@ def _subcall(self, prompt: str, model: str | None = None) -> RLMChatCompletion:
703739
# Calculate remaining budget for child (if budget tracking enabled)
704740
remaining_budget = None
705741
if self.max_budget is not None:
706-
remaining_budget = self.max_budget - self._cumulative_cost
742+
with self._cumulative_cost_lock:
743+
remaining_budget = self.max_budget - self._cumulative_cost
707744
if remaining_budget <= 0:
708745
return RLMChatCompletion(
709746
root_model=resolved_model,
@@ -743,7 +780,7 @@ def _subcall(self, prompt: str, model: str | None = None) -> RLMChatCompletion:
743780
subcall_start = time.perf_counter()
744781
error_msg: str | None = None
745782

746-
# Spawn a child RLM with its own LocalREPL
783+
# Spawn a child RLM, sharing parent's handler when possible
747784
child = RLM(
748785
backend=self.backend,
749786
backend_kwargs=child_backend_kwargs,
@@ -768,16 +805,21 @@ def _subcall(self, prompt: str, model: str | None = None) -> RLMChatCompletion:
768805
# Propagate callbacks to children for nested tracking
769806
on_subcall_start=self.on_subcall_start,
770807
on_subcall_complete=self.on_subcall_complete,
808+
# Share parent's handler/client to avoid per-child TCP server overhead
809+
_shared_lm_handler_address=self._active_handler_address,
810+
_shared_client=self._active_client,
771811
)
772812
try:
773813
result = child.completion(prompt, root_prompt=None)
774814
# Track child's cost in parent's cumulative cost
775815
if result.usage_summary and result.usage_summary.total_cost:
776-
self._cumulative_cost += result.usage_summary.total_cost
816+
with self._cumulative_cost_lock:
817+
self._cumulative_cost += result.usage_summary.total_cost
777818
return result
778819
except BudgetExceededError as e:
779820
# Propagate child's spending to parent
780-
self._cumulative_cost += e.spent
821+
with self._cumulative_cost_lock:
822+
self._cumulative_cost += e.spent
781823
error_msg = f"Budget exceeded - {e}"
782824
return RLMChatCompletion(
783825
root_model=resolved_model,
@@ -849,3 +891,43 @@ def __enter__(self) -> "RLM":
849891
def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
850892
self.close()
851893
return False
894+
895+
896+
class _SharedLMHandlerProxy:
897+
"""Lightweight proxy that reuses a parent's BaseLM client without spawning a TCP server.
898+
899+
Exposes the same interface as LMHandler for completion() and get_usage_summary(),
900+
but start()/stop() are no-ops since the parent owns the actual handler lifecycle.
901+
"""
902+
903+
def __init__(self, client: BaseLM, address: tuple[str, int]):
904+
self._client = client
905+
self._host, self._port = address
906+
907+
@property
908+
def host(self) -> str:
909+
return self._host
910+
911+
@property
912+
def port(self) -> int:
913+
return self._port
914+
915+
@property
916+
def address(self) -> tuple[str, int]:
917+
return (self._host, self._port)
918+
919+
def start(self) -> tuple[str, int]:
920+
return self.address
921+
922+
def stop(self) -> None:
923+
pass # Parent owns the handler
924+
925+
def completion(self, prompt: str, model: str | None = None) -> str:
926+
# BaseLM.completion() does not accept model, but concrete implementations
927+
# (AnthropicClient, OpenAIClient) do. Forward when possible.
928+
if model is not None:
929+
return self._client.completion(prompt, model=model)
930+
return self._client.completion(prompt)
931+
932+
def get_usage_summary(self) -> UsageSummary:
933+
return self._client.get_usage_summary()

0 commit comments

Comments
 (0)