44# This source code is licensed under the terms described in the LICENSE file in
55# the root directory of this source tree.
66
7+ import asyncio
78import base64
89import ssl
910import uuid
1415import httpx
1516from openai import AsyncOpenAI , DefaultAsyncHttpxClient
1617from openai .types .chat import ChatCompletionChunk
17- from pydantic import BaseModel , ConfigDict , Field
18+ from pydantic import BaseModel , ConfigDict , Field , PrivateAttr
1819
1920from ogx .core .request_headers import NeedsRequestProviderData
2021from ogx .log import get_logger
@@ -106,9 +107,7 @@ class OpenAIMixin(NeedsRequestProviderData, ABC, BaseModel):
106107 # Format: {"model_id": {"embedding_dimension": 1536, "context_length": 8192}}
107108 embedding_model_metadata : dict [str , dict [str , int ]] = {}
108109
109- # Cache of available models keyed by model ID
110- # This is set in list_models() and used in check_model_availability()
111- _model_cache : dict [str , Model ] = {}
110+ _model_cache : dict [str , Model ] = PrivateAttr (default_factory = dict )
112111
113112 # Optional field name in provider data to look for API key, which takes precedence
114113 provider_data_api_key_field : str | None = None
@@ -118,6 +117,9 @@ class OpenAIMixin(NeedsRequestProviderData, ABC, BaseModel):
118117 # Trade-off: SSL context changes require server restart
119118 shared_ssl_context : ssl .SSLContext | bool = Field (default_factory = ssl .create_default_context , exclude = True )
120119
120+ _cached_client : AsyncOpenAI | None = PrivateAttr (default = None )
121+ _cached_client_key : tuple [str , str ] | None = PrivateAttr (default = None )
122+
121123 def get_api_key (self ) -> str | None :
122124 """
123125 Get the API key.
@@ -199,10 +201,7 @@ async def list_provider_model_ids(self) -> Iterable[str]:
199201
200202 :return: An iterable of model IDs or None if not implemented
201203 """
202- client = self .client
203- async with client :
204- model_ids = [m .id async for m in client .models .list ()]
205- return model_ids
204+ return [m .id async for m in self .client .models .list ()]
206205
207206 async def initialize (self ) -> None :
208207 """
@@ -215,26 +214,20 @@ async def initialize(self) -> None:
215214 pass
216215
217216 async def shutdown (self ) -> None :
218- """
219- Shutdown the OpenAI mixin.
220-
221- This method provides a default implementation that does nothing.
222- Subclasses can override this method to perform cleanup tasks
223- such as closing connections, releasing resources, etc.
224- """
225- pass
217+ """Shutdown the OpenAI mixin, closing the cached HTTP client."""
218+ if self ._cached_client is not None :
219+ await self ._cached_client .close ()
220+ self ._cached_client = None
221+ self ._cached_client_key = None
226222
227223 @property
228224 def client (self ) -> AsyncOpenAI :
229225 """
230226 Get an AsyncOpenAI client instance.
231227
232- Uses the abstract methods get_api_key() and get_base_url() which must be
233- implemented by child classes.
234-
235- Network configuration from config.network is automatically applied.
236- Users can also provide the API key via the provider data header, which
237- is used instead of any config API key.
228+ Caches the client keyed by (api_key, base_url) for connection reuse.
229+ When the key changes (e.g. per-request provider_data), a new client
230+ is created and cached in its place.
238231 """
239232
240233 api_key = self ._get_api_key_from_config_or_provider_data ()
@@ -244,19 +237,15 @@ def client(self) -> AsyncOpenAI:
244237 message += f' Please provide a valid API key in the provider data header, e.g. x-ogx-provider-data: {{"{ self .provider_data_api_key_field } ": "<API_KEY>"}}.'
245238 raise ValueError (message )
246239
240+ base_url = self .get_base_url ()
241+ cache_key = (api_key , base_url )
242+
243+ if self ._cached_client is not None and self ._cached_client_key == cache_key :
244+ return self ._cached_client
245+
247246 extra_params = self .get_extra_client_params ()
248247 network_kwargs = build_network_client_kwargs (self .config .network )
249248
250- # Handle http_client creation/merging:
251- # - If get_extra_client_params() provides an http_client (e.g., OCI with custom auth),
252- # merge network config into it. The merge behavior:
253- # * Preserves auth from get_extra_client_params() (provider-specific auth like OCI signer)
254- # * Preserves headers from get_extra_client_params() as base
255- # * Applies network config (TLS, proxy, timeout, headers) on top
256- # * Network config headers take precedence over provider headers (allows override)
257- # - Otherwise, if network config exists, create http_client from it
258- # - Otherwise, use a cached SSL context for performance
259- # This allows providers with custom auth to still use standard network settings
260249 if "http_client" in extra_params :
261250 if network_kwargs :
262251 extra_params ["http_client" ] = _merge_network_config_into_client (
@@ -267,12 +256,16 @@ def client(self) -> AsyncOpenAI:
267256 else :
268257 extra_params ["http_client" ] = DefaultAsyncHttpxClient (verify = self .shared_ssl_context )
269258
270- return AsyncOpenAI (
259+ client = AsyncOpenAI (
271260 api_key = api_key ,
272- base_url = self . get_base_url () ,
261+ base_url = base_url ,
273262 ** extra_params ,
274263 )
275264
265+ self ._cached_client = client
266+ self ._cached_client_key = cache_key
267+ return client
268+
276269 def _get_api_key_from_config_or_provider_data (self ) -> str | None :
277270 api_key = self .get_api_key ()
278271
@@ -430,7 +423,7 @@ async def _localize_image_url(m: OpenAIMessageParam) -> OpenAIMessageParam:
430423 # else it's a string and we don't need to modify it
431424 return m
432425
433- messages = [ await _localize_image_url (m ) for m in messages ]
426+ messages = list ( await asyncio . gather ( * [ _localize_image_url (m ) for m in messages ]))
434427
435428 request_params = await prepare_openai_completion_params (
436429 model = provider_model_id ,
0 commit comments