Coverage for src/chat_limiter/adapters.py: 92%
173 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-09-15 12:11 +0100
« prev ^ index » next coverage.py v7.9.2, created at 2025-09-15 12:11 +0100
1"""
2Provider-specific adapters for converting between our unified types and provider APIs.
3"""
5import time
6import warnings
7from abc import ABC, abstractmethod
8from typing import Any
10from .providers import Provider
11from .types import (
12 ChatCompletionRequest,
13 ChatCompletionResponse,
14 Choice,
15 Message,
16 MessageRole,
17 Usage,
18)
21class ProviderAdapter(ABC):
22 """Abstract base class for provider-specific adapters."""
24 def is_reasoning_model(self, model_name: str) -> bool:
25 """Check if the model is a reasoning model (o1, o3, o4 series)."""
26 # Handle prefixed models (e.g., "openai/o3-mini")
27 if "/" in model_name:
28 # Extract the base model name after the "/"
29 base_model = model_name.split("/", 1)[1]
30 return base_model.startswith(("o1", "o3", "o4"))
32 # Handle non-prefixed models
33 return model_name.startswith(("o1", "o3", "o4"))
35 @abstractmethod
36 def format_request(self, request: ChatCompletionRequest) -> dict[str, Any]:
37 """Convert our request format to provider-specific format."""
38 pass
40 @abstractmethod
41 def parse_response(
42 self,
43 response_data: dict[str, Any],
44 original_request: ChatCompletionRequest
45 ) -> ChatCompletionResponse:
46 """Convert provider response to our unified format."""
47 pass
49 @abstractmethod
50 def get_endpoint(self) -> str:
51 """Get the API endpoint for this provider."""
52 pass
55class OpenAIAdapter(ProviderAdapter):
56 """Adapter for OpenAI API."""
58 def format_request(self, request: ChatCompletionRequest) -> dict[str, Any]:
59 """Convert to OpenAI format."""
60 # Convert messages
61 messages: list[dict[str, Any]] = []
62 for msg in request.messages:
63 messages.append({
64 "role": msg.role.value,
65 "content": msg.content
66 })
68 # Build request
69 openai_request: dict[str, Any] = {
70 "model": request.model,
71 "messages": messages,
72 }
74 # Add optional parameters
75 if request.max_tokens is not None:
76 # Use max_completion_tokens for reasoning models (o1, o3, o4)
77 if self.is_reasoning_model(request.model):
78 openai_request["max_completion_tokens"] = request.max_tokens
79 else:
80 openai_request["max_tokens"] = request.max_tokens
82 # Handle temperature for reasoning models
83 if self.is_reasoning_model(request.model):
84 # For reasoning models, default to temperature=1
85 default_temperature = 1.0
87 if request.temperature is not None:
88 # If user provided a different temperature, warn them and use temperature=1
89 if request.temperature != default_temperature:
90 warnings.warn(
91 f"WARNING: Model '{request.model}' is a reasoning model that requires temperature=1. "
92 f"Your specified temperature={request.temperature} will be overridden to temperature=1.",
93 UserWarning
94 )
95 print(f"WARNING: Model '{request.model}' is a reasoning model that requires temperature=1. "
96 f"Your specified temperature={request.temperature} will be overridden to temperature=1.")
98 # Always use temperature=1 for reasoning models
99 openai_request["temperature"] = default_temperature
100 else:
101 # For non-reasoning models, use the provided temperature
102 if request.temperature is not None:
103 openai_request["temperature"] = request.temperature
105 if request.top_p is not None:
106 openai_request["top_p"] = request.top_p
107 if request.stop is not None:
108 openai_request["stop"] = request.stop
109 if request.stream:
110 openai_request["stream"] = request.stream
111 if request.frequency_penalty is not None:
112 openai_request["frequency_penalty"] = request.frequency_penalty
113 if request.presence_penalty is not None:
114 openai_request["presence_penalty"] = request.presence_penalty
115 if request.seed is not None:
116 openai_request["seed"] = request.seed
118 # Add reasoning parameter for thinking models
119 if (request.reasoning_effort is not None and
120 self.is_reasoning_model(request.model)):
121 openai_request["reasoning"] = {"effort": request.reasoning_effort}
123 return openai_request
125 def parse_response(
126 self,
127 response_data: dict[str, Any],
128 original_request: ChatCompletionRequest
129 ) -> ChatCompletionResponse:
130 """Parse OpenAI response."""
131 # Check for errors first
132 success = True
133 error_message = None
135 if "error" in response_data:
136 success = False
137 error_data = response_data["error"]
138 error_message = error_data.get("message", "Unknown error")
140 choices = []
141 for choice_data in response_data.get("choices", []):
142 message_data = choice_data.get("message", {})
143 message = Message(
144 role=MessageRole(message_data.get("role", "assistant")),
145 content=message_data.get("content", "")
146 )
147 choice = Choice(
148 index=choice_data.get("index", 0),
149 message=message,
150 finish_reason=choice_data.get("finish_reason")
151 )
152 choices.append(choice)
154 # Parse usage
155 usage = None
156 if "usage" in response_data:
157 usage_data = response_data["usage"]
158 usage = Usage(
159 prompt_tokens=usage_data.get("prompt_tokens", 0),
160 completion_tokens=usage_data.get("completion_tokens", 0),
161 total_tokens=usage_data.get("total_tokens", 0)
162 )
164 return ChatCompletionResponse(
165 id=response_data.get("id", ""),
166 model=response_data.get("model", original_request.model),
167 choices=choices,
168 usage=usage,
169 created=response_data.get("created"),
170 success=success,
171 error_message=error_message,
172 provider="openai",
173 raw_response=response_data
174 )
176 def get_endpoint(self) -> str:
177 return "/chat/completions"
180class AnthropicAdapter(ProviderAdapter):
181 """Adapter for Anthropic API."""
183 def format_request(self, request: ChatCompletionRequest) -> dict[str, Any]:
184 """Convert to Anthropic format."""
185 # Anthropic has a different message format
186 messages: list[dict[str, Any]] = []
187 system_message: str | None = None
189 for msg in request.messages:
190 if msg.role == MessageRole.SYSTEM:
191 # Anthropic handles system messages separately
192 system_message = msg.content
193 else:
194 messages.append({
195 "role": msg.role.value,
196 "content": msg.content
197 })
199 # Build request
200 anthropic_request: dict[str, Any] = {
201 "model": request.model,
202 "messages": messages,
203 "max_tokens": request.max_tokens or 1024, # Required for Anthropic
204 }
206 # Add system message if present
207 if system_message:
208 anthropic_request["system"] = system_message
210 # Add optional parameters
211 if request.temperature is not None:
212 anthropic_request["temperature"] = request.temperature
213 if request.top_p is not None:
214 anthropic_request["top_p"] = request.top_p
215 if request.stop is not None:
216 anthropic_request["stop_sequences"] = (
217 [request.stop] if isinstance(request.stop, str) else request.stop
218 )
219 if request.stream:
220 anthropic_request["stream"] = request.stream
221 if request.top_k is not None:
222 anthropic_request["top_k"] = request.top_k
223 if request.seed is not None:
224 anthropic_request["seed"] = request.seed
226 return anthropic_request
228 def parse_response(
229 self,
230 response_data: dict[str, Any],
231 original_request: ChatCompletionRequest
232 ) -> ChatCompletionResponse:
233 """Parse Anthropic response."""
234 # Check for errors first
235 success = True
236 error_message = None
238 if "error" in response_data:
239 success = False
240 error_data = response_data["error"]
241 error_message = error_data.get("message", "Unknown error")
243 # Anthropic returns content differently
244 content_blocks = response_data.get("content", [])
245 content = ""
246 if content_blocks:
247 # Extract text from content blocks
248 for block in content_blocks:
249 if block.get("type") == "text":
250 content += block.get("text", "")
252 message = Message(
253 role=MessageRole.ASSISTANT,
254 content=content
255 )
257 choice = Choice(
258 index=0,
259 message=message,
260 finish_reason=response_data.get("stop_reason")
261 )
263 # Parse usage
264 usage = None
265 if "usage" in response_data:
266 usage_data = response_data["usage"]
267 usage = Usage(
268 prompt_tokens=usage_data.get("input_tokens", 0),
269 completion_tokens=usage_data.get("output_tokens", 0),
270 total_tokens=usage_data.get("input_tokens", 0) + usage_data.get("output_tokens", 0)
271 )
273 return ChatCompletionResponse(
274 id=response_data.get("id", ""),
275 model=response_data.get("model", original_request.model),
276 choices=[choice],
277 usage=usage,
278 created=int(time.time()), # Anthropic doesn't provide created timestamp
279 success=success,
280 error_message=error_message,
281 provider="anthropic",
282 raw_response=response_data
283 )
285 def get_endpoint(self) -> str:
286 return "/messages"
289class OpenRouterAdapter(ProviderAdapter):
290 """Adapter for OpenRouter API."""
292 def format_request(self, request: ChatCompletionRequest) -> dict[str, Any]:
293 """Convert to OpenRouter format (similar to OpenAI)."""
294 # OpenRouter uses OpenAI-compatible format
295 messages: list[dict[str, Any]] = []
296 for msg in request.messages:
297 messages.append({
298 "role": msg.role.value,
299 "content": msg.content
300 })
302 # Build request
303 openrouter_request: dict[str, Any] = {
304 "model": request.model,
305 "messages": messages,
306 }
308 # Add optional parameters
309 if request.max_tokens is not None:
310 openrouter_request["max_tokens"] = request.max_tokens
311 if request.temperature is not None:
312 openrouter_request["temperature"] = request.temperature
313 if request.top_p is not None:
314 openrouter_request["top_p"] = request.top_p
315 if request.stop is not None:
316 openrouter_request["stop"] = request.stop
317 if request.stream:
318 openrouter_request["stream"] = request.stream
319 if request.frequency_penalty is not None:
320 openrouter_request["frequency_penalty"] = request.frequency_penalty
321 if request.presence_penalty is not None:
322 openrouter_request["presence_penalty"] = request.presence_penalty
323 if request.top_k is not None:
324 openrouter_request["top_k"] = request.top_k
325 if request.seed is not None:
326 openrouter_request["seed"] = request.seed
328 # Add reasoning parameter for thinking models
329 if (request.reasoning_effort is not None and
330 self.is_reasoning_model(request.model)):
331 openrouter_request["reasoning"] = {"effort": request.reasoning_effort}
333 # Add provider routing if specified
334 if request.providers is not None:
335 openrouter_request["provider"] = {
336 "order": request.providers,
337 "allow_fallbacks": False
338 }
340 return openrouter_request
342 def parse_response(
343 self,
344 response_data: dict[str, Any],
345 original_request: ChatCompletionRequest
346 ) -> ChatCompletionResponse:
347 """Parse OpenRouter response (similar to OpenAI)."""
348 # Check for errors first
349 success = True
350 error_message = None
352 if "error" in response_data:
353 success = False
354 error_data = response_data["error"]
355 error_message = error_data.get("message", "Unknown error")
357 choices = []
358 for choice_data in response_data.get("choices", []):
359 message_data = choice_data.get("message", {})
360 message = Message(
361 role=MessageRole(message_data.get("role", "assistant")),
362 content=message_data.get("content", "")
363 )
364 choice = Choice(
365 index=choice_data.get("index", 0),
366 message=message,
367 finish_reason=choice_data.get("finish_reason")
368 )
369 choices.append(choice)
371 # Parse usage
372 usage = None
373 if "usage" in response_data:
374 usage_data = response_data["usage"]
375 usage = Usage(
376 prompt_tokens=usage_data.get("prompt_tokens", 0),
377 completion_tokens=usage_data.get("completion_tokens", 0),
378 total_tokens=usage_data.get("total_tokens", 0)
379 )
381 return ChatCompletionResponse(
382 id=response_data.get("id", ""),
383 model=response_data.get("model", original_request.model),
384 choices=choices,
385 usage=usage,
386 created=response_data.get("created"),
387 success=success,
388 error_message=error_message,
389 provider="openrouter",
390 raw_response=response_data
391 )
393 def get_endpoint(self) -> str:
394 return "/chat/completions"
397# Provider adapter registry
398PROVIDER_ADAPTERS = {
399 Provider.OPENAI: OpenAIAdapter(),
400 Provider.ANTHROPIC: AnthropicAdapter(),
401 Provider.OPENROUTER: OpenRouterAdapter(),
402}
405def get_adapter(provider: Provider) -> ProviderAdapter:
406 """Get the appropriate adapter for a provider."""
407 return PROVIDER_ADAPTERS[provider]