Skip to content

Commit 90bd477

Browse files
authored
timeouts per client (#108)
1 parent b35a881 commit 90bd477

8 files changed

Lines changed: 202 additions & 10 deletions

File tree

‎rlm/clients/anthropic.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ def __init__(
2020
**kwargs,
2121
):
2222
super().__init__(model_name=model_name, **kwargs)
23-
self.client = anthropic.Anthropic(api_key=api_key)
24-
self.async_client = anthropic.AsyncAnthropic(api_key=api_key)
23+
self.client = anthropic.Anthropic(api_key=api_key, timeout=self.timeout)
24+
self.async_client = anthropic.AsyncAnthropic(api_key=api_key, timeout=self.timeout)
2525
self.model_name = model_name
2626
self.max_tokens = max_tokens
2727

‎rlm/clients/azure_openai.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,14 @@ def __init__(
5353
azure_endpoint=azure_endpoint,
5454
api_version=api_version,
5555
azure_deployment=azure_deployment,
56+
timeout=self.timeout,
5657
)
5758
self.async_client = openai.AsyncAzureOpenAI(
5859
api_key=api_key,
5960
azure_endpoint=azure_endpoint,
6061
api_version=api_version,
6162
azure_deployment=azure_deployment,
63+
timeout=self.timeout,
6264
)
6365
self.model_name = model_name
6466
self.azure_deployment = azure_deployment

‎rlm/clients/base_lm.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,19 @@
33

44
from rlm.core.types import ModelUsageSummary, UsageSummary
55

6+
# Default timeout for LM API calls (in seconds)
7+
DEFAULT_TIMEOUT: float = 300.0
8+
69

710
class BaseLM(ABC):
811
"""
912
Base class for all language model routers / clients. When the RLM makes sub-calls, it currently
1013
does so in a model-agnostic way, so this class provides a base interface for all language models.
1114
"""
1215

13-
def __init__(self, model_name: str, **kwargs):
16+
def __init__(self, model_name: str, timeout: float = DEFAULT_TIMEOUT, **kwargs):
1417
self.model_name = model_name
18+
self.timeout = timeout
1519
self.kwargs = kwargs
1620

1721
@abstractmethod

‎rlm/clients/gemini.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ def __init__(
3636
"Gemini API key is required. Set GEMINI_API_KEY env var or pass api_key."
3737
)
3838

39-
self.client = genai.Client(api_key=api_key)
39+
# Configure HTTP options with timeout
40+
http_options = types.HttpOptions(timeout=int(self.timeout * 1000)) # milliseconds
41+
self.client = genai.Client(api_key=api_key, http_options=http_options)
4042
self.model_name = model_name
4143

4244
# Per-model usage tracking

‎rlm/clients/litellm.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def completion(self, prompt: str | list[dict[str, Any]], model: str | None = Non
4343
if not model:
4444
raise ValueError("Model name is required for LiteLLM client.")
4545

46-
kwargs = {"model": model, "messages": messages}
46+
kwargs = {"model": model, "messages": messages, "timeout": self.timeout}
4747
if self.api_key:
4848
kwargs["api_key"] = self.api_key
4949
if self.api_base:
@@ -67,7 +67,7 @@ async def acompletion(
6767
if not model:
6868
raise ValueError("Model name is required for LiteLLM client.")
6969

70-
kwargs = {"model": model, "messages": messages}
70+
kwargs = {"model": model, "messages": messages, "timeout": self.timeout}
7171
if self.api_key:
7272
kwargs["api_key"] = self.api_key
7373
if self.api_base:

‎rlm/clients/openai.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,10 @@ def __init__(
4040
api_key = DEFAULT_VERCEL_API_KEY
4141

4242
# For vLLM, set base_url to local vLLM server address.
43-
self.client = openai.OpenAI(api_key=api_key, base_url=base_url)
44-
self.async_client = openai.AsyncOpenAI(api_key=api_key, base_url=base_url)
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+
)
4547
self.model_name = model_name
4648

4749
# Per-model usage tracking

‎rlm/clients/portkey.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ def __init__(
2121
**kwargs,
2222
):
2323
super().__init__(model_name=model_name, **kwargs)
24-
self.client = Portkey(api_key=api_key, base_url=base_url)
25-
self.async_client = AsyncPortkey(api_key=api_key, base_url=base_url)
24+
self.client = Portkey(api_key=api_key, base_url=base_url, timeout=self.timeout)
25+
self.async_client = AsyncPortkey(api_key=api_key, base_url=base_url, timeout=self.timeout)
2626
self.model_name = model_name
2727

2828
# Per-model usage tracking

‎tests/clients/test_timeout.py‎

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
"""Tests for client timeout functionality."""
2+
3+
from unittest.mock import MagicMock, patch
4+
5+
import httpx
6+
import pytest
7+
8+
from rlm.clients.base_lm import DEFAULT_TIMEOUT
9+
10+
11+
class TestDefaultTimeout:
12+
"""Tests for the default timeout constant."""
13+
14+
def test_default_timeout_value(self):
15+
"""Default timeout should be 300 seconds."""
16+
assert DEFAULT_TIMEOUT == 300.0
17+
18+
def test_base_lm_stores_timeout(self):
19+
"""BaseLM should store timeout in instance."""
20+
from rlm.clients.openai import OpenAIClient
21+
22+
with patch("rlm.clients.openai.openai.OpenAI"):
23+
with patch("rlm.clients.openai.openai.AsyncOpenAI"):
24+
client = OpenAIClient(api_key="test-key", model_name="gpt-4o")
25+
assert client.timeout == DEFAULT_TIMEOUT
26+
27+
def test_custom_timeout_override(self):
28+
"""Custom timeout should override default."""
29+
from rlm.clients.openai import OpenAIClient
30+
31+
with patch("rlm.clients.openai.openai.OpenAI"):
32+
with patch("rlm.clients.openai.openai.AsyncOpenAI"):
33+
client = OpenAIClient(api_key="test-key", model_name="gpt-4o", timeout=60.0)
34+
assert client.timeout == 60.0
35+
36+
37+
class TestOpenAIClientTimeout:
38+
"""Tests for OpenAI client timeout."""
39+
40+
def test_timeout_passed_to_client(self):
41+
"""Timeout should be passed to OpenAI client."""
42+
from rlm.clients.openai import OpenAIClient
43+
44+
with patch("rlm.clients.openai.openai.OpenAI") as mock_openai:
45+
with patch("rlm.clients.openai.openai.AsyncOpenAI") as mock_async:
46+
OpenAIClient(api_key="test-key", model_name="gpt-4o", timeout=120.0)
47+
48+
# Check sync client received timeout
49+
mock_openai.assert_called_once()
50+
call_kwargs = mock_openai.call_args[1]
51+
assert call_kwargs["timeout"] == 120.0
52+
53+
# Check async client received timeout
54+
mock_async.assert_called_once()
55+
async_call_kwargs = mock_async.call_args[1]
56+
assert async_call_kwargs["timeout"] == 120.0
57+
58+
def test_timeout_raises_exception(self):
59+
"""Timeout should raise appropriate exception."""
60+
from rlm.clients.openai import OpenAIClient
61+
62+
# Create a mock client that raises timeout
63+
mock_client = MagicMock()
64+
mock_client.chat.completions.create.side_effect = httpx.TimeoutException(
65+
"Connection timed out"
66+
)
67+
68+
with patch("rlm.clients.openai.openai.OpenAI", return_value=mock_client):
69+
with patch("rlm.clients.openai.openai.AsyncOpenAI"):
70+
client = OpenAIClient(api_key="test-key", model_name="gpt-4o", timeout=0.001)
71+
72+
with pytest.raises(httpx.TimeoutException):
73+
client.completion("Hello")
74+
75+
76+
class TestAnthropicClientTimeout:
77+
"""Tests for Anthropic client timeout."""
78+
79+
def test_timeout_passed_to_client(self):
80+
"""Timeout should be passed to Anthropic client."""
81+
from rlm.clients.anthropic import AnthropicClient
82+
83+
with patch("rlm.clients.anthropic.anthropic.Anthropic") as mock_anthropic:
84+
with patch("rlm.clients.anthropic.anthropic.AsyncAnthropic") as mock_async:
85+
AnthropicClient(
86+
api_key="test-key", model_name="claude-sonnet-4-20250514", timeout=120.0
87+
)
88+
89+
mock_anthropic.assert_called_once()
90+
call_kwargs = mock_anthropic.call_args[1]
91+
assert call_kwargs["timeout"] == 120.0
92+
93+
mock_async.assert_called_once()
94+
async_call_kwargs = mock_async.call_args[1]
95+
assert async_call_kwargs["timeout"] == 120.0
96+
97+
98+
class TestAzureOpenAIClientTimeout:
99+
"""Tests for Azure OpenAI client timeout."""
100+
101+
def test_timeout_passed_to_client(self):
102+
"""Timeout should be passed to Azure OpenAI client."""
103+
from rlm.clients.azure_openai import AzureOpenAIClient
104+
105+
with patch("rlm.clients.azure_openai.openai.AzureOpenAI") as mock_azure:
106+
with patch("rlm.clients.azure_openai.openai.AsyncAzureOpenAI") as mock_async:
107+
AzureOpenAIClient(
108+
api_key="test-key",
109+
model_name="gpt-4o",
110+
azure_endpoint="https://test.openai.azure.com",
111+
timeout=120.0,
112+
)
113+
114+
mock_azure.assert_called_once()
115+
call_kwargs = mock_azure.call_args[1]
116+
assert call_kwargs["timeout"] == 120.0
117+
118+
mock_async.assert_called_once()
119+
async_call_kwargs = mock_async.call_args[1]
120+
assert async_call_kwargs["timeout"] == 120.0
121+
122+
123+
class TestPortkeyClientTimeout:
124+
"""Tests for Portkey client timeout."""
125+
126+
def test_timeout_passed_to_client(self):
127+
"""Timeout should be passed to Portkey client."""
128+
from rlm.clients.portkey import PortkeyClient
129+
130+
with patch("rlm.clients.portkey.Portkey") as mock_portkey:
131+
with patch("rlm.clients.portkey.AsyncPortkey") as mock_async:
132+
PortkeyClient(api_key="test-key", model_name="gpt-4o", timeout=120.0)
133+
134+
mock_portkey.assert_called_once()
135+
call_kwargs = mock_portkey.call_args[1]
136+
assert call_kwargs["timeout"] == 120.0
137+
138+
mock_async.assert_called_once()
139+
async_call_kwargs = mock_async.call_args[1]
140+
assert async_call_kwargs["timeout"] == 120.0
141+
142+
143+
class TestLiteLLMClientTimeout:
144+
"""Tests for LiteLLM client timeout."""
145+
146+
def test_timeout_passed_to_completion(self):
147+
"""Timeout should be passed to litellm.completion call."""
148+
pytest.importorskip("litellm")
149+
from rlm.clients.litellm import LiteLLMClient
150+
151+
mock_response = MagicMock()
152+
mock_response.choices = [MagicMock()]
153+
mock_response.choices[0].message.content = "Hello"
154+
mock_response.usage.prompt_tokens = 10
155+
mock_response.usage.completion_tokens = 5
156+
mock_response.usage.total_tokens = 15
157+
158+
with patch(
159+
"rlm.clients.litellm.litellm.completion", return_value=mock_response
160+
) as mock_completion:
161+
client = LiteLLMClient(model_name="gpt-4o", timeout=120.0)
162+
client.completion("Hello")
163+
164+
mock_completion.assert_called_once()
165+
call_kwargs = mock_completion.call_args[1]
166+
assert call_kwargs["timeout"] == 120.0
167+
168+
169+
class TestGeminiClientTimeout:
170+
"""Tests for Gemini client timeout."""
171+
172+
def test_timeout_passed_to_client(self):
173+
"""Timeout should be passed to Gemini client via http_options."""
174+
from rlm.clients.gemini import GeminiClient
175+
176+
with patch("rlm.clients.gemini.genai.Client") as mock_genai:
177+
GeminiClient(api_key="test-key", model_name="gemini-2.5-flash", timeout=120.0)
178+
179+
mock_genai.assert_called_once()
180+
call_kwargs = mock_genai.call_args[1]
181+
# Gemini uses milliseconds
182+
assert call_kwargs["http_options"].timeout == 120000

0 commit comments

Comments
 (0)