FreshPixels commited on
Commit
fc6a459
·
verified ·
1 Parent(s): 649caf1

Create llm/providers/openai_provider.py

Browse files
Files changed (1) hide show
  1. llm/providers/openai_provider.py +186 -0
llm/providers/openai_provider.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from typing import Any
5
+
6
+ import openai
7
+
8
+ from core.logging.logger import get_logger
9
+ from llm.providers.base_provider import (
10
+ BaseProvider,
11
+ LLMProviderError,
12
+ LLMMessage,
13
+ LLMRequest,
14
+ LLMResponse,
15
+ )
16
+
17
+ logger = get_logger(__name__)
18
+
19
+ # Mapping from our role Literal to OpenAI role strings
20
+ _ROLE_MAP: dict[str, str] = {
21
+ "system": "system",
22
+ "user": "user",
23
+ "assistant": "assistant",
24
+ "tool": "tool",
25
+ }
26
+
27
+
28
+ class OpenAIRateLimitError(LLMProviderError):
29
+ """OpenAI API rate limit exceeded."""
30
+
31
+ def __init__(self, message: str) -> None:
32
+ super().__init__(message, provider_name="openai")
33
+
34
+
35
+ class OpenAITimeoutError(LLMProviderError):
36
+ """OpenAI API request timed out."""
37
+
38
+ def __init__(self, message: str) -> None:
39
+ super().__init__(message, provider_name="openai")
40
+
41
+
42
+ class OpenAIAPIError(LLMProviderError):
43
+ """OpenAI API returned an error response."""
44
+
45
+ def __init__(self, message: str, status_code: int | None = None) -> None:
46
+ self.status_code = status_code
47
+ super().__init__(message, provider_name="openai")
48
+
49
+
50
+ class OpenAIProvider(BaseProvider):
51
+ """Concrete LLM provider implementation for OpenAI.
52
+
53
+ Uses the ``openai`` Python SDK (>=1.0.0) to call the OpenAI Chat
54
+ Completions API.
55
+
56
+ Args:
57
+ api_key: OpenAI API key (sk-...).
58
+ model: Default model to use (e.g. "gpt-4o-mini").
59
+ default_timeout: Request timeout in seconds.
60
+ organization: Optional OpenAI organization ID.
61
+ base_url: Optional custom base URL (for Azure, proxies, etc.).
62
+ """
63
+
64
+ def __init__(
65
+ self,
66
+ api_key: str,
67
+ model: str = "gpt-4o-mini",
68
+ default_timeout: float = 60.0,
69
+ organization: str | None = None,
70
+ base_url: str | None = None,
71
+ ) -> None:
72
+ super().__init__(name="openai")
73
+ self._model = model
74
+ self._default_timeout = default_timeout
75
+
76
+ if not api_key or not api_key.strip():
77
+ raise ValueError("OpenAI API key must not be empty")
78
+
79
+ client_kwargs: dict[str, Any] = {
80
+ "api_key": api_key.strip(),
81
+ "timeout": default_timeout,
82
+ }
83
+ if organization:
84
+ client_kwargs["organization"] = organization
85
+ if base_url:
86
+ client_kwargs["base_url"] = base_url
87
+
88
+ self._client = openai.AsyncOpenAI(**client_kwargs)
89
+
90
+ @property
91
+ def model(self) -> str:
92
+ """Return the default model identifier."""
93
+ return self._model
94
+
95
+ async def generate(self, request: LLMRequest) -> LLMResponse:
96
+ """Generate a response using the OpenAI Chat Completions API.
97
+
98
+ Args:
99
+ request: The LLM request containing messages and parameters.
100
+
101
+ Returns:
102
+ LLMResponse with the generated content and usage metadata.
103
+
104
+ Raises:
105
+ LLMProviderError: On any OpenAI API failure.
106
+ """
107
+ # Build messages payload
108
+ openai_messages: list[dict[str, str]] = []
109
+ for msg in request.messages:
110
+ openai_messages.append({
111
+ "role": _ROLE_MAP[msg.role],
112
+ "content": msg.content,
113
+ })
114
+
115
+ model = request.model or self._model
116
+
117
+ try:
118
+ response = await self._client.chat.completions.create(
119
+ model=model,
120
+ messages=openai_messages,
121
+ temperature=request.temperature,
122
+ max_tokens=request.max_tokens,
123
+ )
124
+
125
+ except openai.RateLimitError as exc:
126
+ raise OpenAIRateLimitError(
127
+ f"OpenAI rate limit exceeded: {exc}"
128
+ ) from exc
129
+
130
+ except openai.APITimeoutError as exc:
131
+ raise OpenAITimeoutError(
132
+ f"OpenAI request timed out after {self._default_timeout}s: {exc}"
133
+ ) from exc
134
+
135
+ except openai.APIConnectionError as exc:
136
+ raise OpenAIAPIError(
137
+ f"OpenAI connection error: {exc}"
138
+ ) from exc
139
+
140
+ except openai.BadRequestError as exc:
141
+ raise OpenAIAPIError(
142
+ f"OpenAI bad request: {exc}",
143
+ status_code=getattr(exc, "status_code", None),
144
+ ) from exc
145
+
146
+ except openai.APIStatusError as exc:
147
+ raise OpenAIAPIError(
148
+ f"OpenAI API error (status={exc.status_code}): {exc.message}",
149
+ status_code=exc.status_code,
150
+ ) from exc
151
+
152
+ except asyncio.CancelledError:
153
+ # Do not intercept cancellation — let it propagate
154
+ raise
155
+
156
+ except Exception as exc:
157
+ raise LLMProviderError(
158
+ f"Unexpected OpenAI error: {exc}",
159
+ provider_name="openai",
160
+ ) from exc
161
+
162
+ # Extract response content
163
+ choice = response.choices[0] if response.choices else None
164
+ if choice is None or choice.message.content is None:
165
+ raise OpenAIAPIError(
166
+ "OpenAI returned empty response — no choices"
167
+ )
168
+
169
+ usage_data: dict[str, int] = {}
170
+ if response.usage:
171
+ usage_data = {
172
+ "prompt_tokens": response.usage.prompt_tokens,
173
+ "completion_tokens": response.usage.completion_tokens,
174
+ "total_tokens": response.usage.total_tokens,
175
+ }
176
+
177
+ return LLMResponse(
178
+ content=choice.message.content,
179
+ model=response.model or model,
180
+ provider="openai",
181
+ usage=usage_data,
182
+ )
183
+
184
+ def __repr__(self) -> str:
185
+ return f"<OpenAIProvider model='{self._model}'>"
186
+