Skip to content

Commit 622346c

Browse files
committed
Add API client module
1 parent 2440a44 commit 622346c

File tree

1 file changed

+190
-0
lines changed

1 file changed

+190
-0
lines changed
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
"""
2+
API Client Module
3+
4+
Manages communication with OpenRouter and Perplexity APIs, handling authentication,
5+
request processing, and error management.
6+
"""
7+
8+
import logging
9+
from typing import Any, NoReturn, cast
10+
11+
import httpx
12+
from tenacity import (
13+
AsyncRetrying,
14+
RetryError,
15+
before_sleep_log,
16+
retry_if_exception,
17+
stop_after_attempt,
18+
wait_exponential,
19+
)
20+
21+
from .config import PROVIDER_CONFIG
22+
from .types import (
23+
APIKeyError,
24+
APIRequestError,
25+
ApiResponse,
26+
ChatCompletionMessage,
27+
ChatCompletionResponse,
28+
ProviderType,
29+
)
30+
31+
logger = logging.getLogger(__name__)
32+
33+
34+
def is_retryable_error(exc: BaseException) -> bool:
35+
"""
36+
Determines if an exception qualifies for retry based on error type and conditions.
37+
38+
The following conditions are considered retryable:
39+
1. Network timeouts
40+
2. Connection errors
41+
3. Rate limit responses (HTTP 429)
42+
4. Server errors (HTTP 5xx)
43+
44+
Args:
45+
exc: The exception to evaluate
46+
47+
Returns:
48+
bool: True if the error is retryable, False otherwise
49+
"""
50+
if isinstance(exc, APIRequestError):
51+
cause = exc.__cause__
52+
if cause:
53+
# Retry on timeout or connection errors
54+
if isinstance(cause, httpx.TimeoutException | httpx.TransportError):
55+
return True
56+
# Check status code for HTTP errors
57+
if isinstance(cause, httpx.HTTPStatusError):
58+
return cause.response.status_code == 429 or (500 <= cause.response.status_code < 600)
59+
return False
60+
61+
62+
def raise_api_error(message: str, cause: Exception | None = None) -> NoReturn:
63+
"""
64+
Raises an APIRequestError with the specified message and optional cause.
65+
66+
Args:
67+
message: Error description
68+
cause: The underlying exception that caused this error
69+
70+
Raises:
71+
APIRequestError: Always raises this exception
72+
"""
73+
if cause:
74+
raise APIRequestError(message) from cause
75+
raise APIRequestError(message)
76+
77+
78+
async def call_api(
79+
endpoint: str, payload: dict[str, Any], token: str, provider: ProviderType
80+
) -> ChatCompletionResponse:
81+
"""
82+
Executes an API request with retry logic and returns the parsed response.
83+
84+
Args:
85+
endpoint: Target API endpoint URL
86+
payload: Request payload data
87+
token: API authentication token
88+
provider: API provider type
89+
90+
Returns:
91+
ChatCompletionResponse: Parsed API response data
92+
93+
Raises:
94+
APIRequestError: When the API request fails after all retry attempts
95+
"""
96+
headers: dict[str, str] = {
97+
"Authorization": f"Bearer {token}",
98+
"Content-Type": "application/json",
99+
}
100+
101+
if provider == "openrouter":
102+
headers.update(
103+
{
104+
"HTTP-Referer": "https://github.com/code-yeongyu/perplexity-advanced-mcp",
105+
"X-Title": "Perplexity Advanced MCP",
106+
}
107+
)
108+
109+
# Disable HTTPX timeouts to let MCP client handle timeout management
110+
async with httpx.AsyncClient(timeout=None) as client:
111+
try:
112+
# Implement retry logic using Tenacity
113+
async for attempt in AsyncRetrying(
114+
retry=retry_if_exception(is_retryable_error),
115+
stop=stop_after_attempt(5), # Maximum 5 attempts
116+
wait=wait_exponential(multiplier=1, min=1, max=10), # Exponential backoff between 1-10 seconds
117+
before_sleep=before_sleep_log(logger, logging.WARNING),
118+
reraise=True,
119+
):
120+
with attempt:
121+
response: httpx.Response = await client.post(endpoint, json=payload, headers=headers)
122+
response.raise_for_status()
123+
return cast(ChatCompletionResponse, response.json())
124+
125+
except RetryError as e:
126+
# All retry attempts have failed
127+
raise_api_error(f"All retry attempts failed: {str(e.__cause__)}", e)
128+
except httpx.HTTPError as e:
129+
# Non-retryable HTTP error
130+
raise_api_error(f"API request failed: {str(e)}", e)
131+
132+
# This code is unreachable but needed to satisfy the type checker
133+
raise_api_error("Unexpected error occurred")
134+
135+
136+
async def call_provider(
137+
provider: ProviderType,
138+
model: str,
139+
messages: list[dict[str, str]],
140+
include_reasoning: bool = False,
141+
) -> ApiResponse:
142+
"""
143+
Calls the specified provider's API and returns a parsed response.
144+
145+
Args:
146+
provider: Target API provider
147+
model: Model identifier to use
148+
messages: List of conversation messages
149+
include_reasoning: Whether to include reasoning in the response (OpenRouter only)
150+
151+
Returns:
152+
ApiResponse: Parsed API response containing content and optional reasoning
153+
154+
Raises:
155+
APIKeyError: When the required API key is not configured
156+
APIRequestError: When the API request fails
157+
"""
158+
# Validate token
159+
token: str | None = PROVIDER_CONFIG[provider]["key"]
160+
if not token:
161+
raise APIKeyError(f"{provider} API key not set")
162+
163+
# Configure provider-specific endpoints
164+
endpoints: dict[ProviderType, str] = {
165+
"openrouter": "https://openrouter.ai/api/v1/chat/completions",
166+
"perplexity": "https://api.perplexity.ai/chat/completions",
167+
}
168+
endpoint: str = endpoints[provider]
169+
170+
# Prepare request payload
171+
payload: dict[str, Any] = {
172+
"model": model,
173+
"messages": messages,
174+
}
175+
if provider == "openrouter":
176+
payload["include_reasoning"] = include_reasoning
177+
178+
# Make API call and process response
179+
data: ChatCompletionResponse = await call_api(endpoint, payload, token, provider)
180+
message_data = cast(ChatCompletionMessage, (data.get("choices", [{}])[0]).get("message", {}))
181+
182+
result: ApiResponse = {
183+
"content": message_data.get("content", ""),
184+
"reasoning": None,
185+
}
186+
reasoning = message_data.get("reasoning")
187+
if reasoning is not None:
188+
result["reasoning"] = reasoning
189+
190+
return result

0 commit comments

Comments
 (0)