From 21fe72a5532f2729e0961c902b20a4b84776509a Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Fri, 19 Dec 2025 13:05:33 +0100 Subject: [PATCH] refactor(client): add APIMixin abstract base for type-safe provider mixins Introduce APIMixin class as abstract base for all provider API mixins: - Declares model, auth, provider attributes with proper type hints - Declares abstract http_client property returning HTTPClient - Provides _build_request, _build_metadata, _handle_error_response stubs that chain to Client via MRO Updates all 15 provider client mixins to inherit from APIMixin: - anthropic/messages, bfl/images, byteplus/images, byteplus/videos - cohere/chat, elevenlabs/text_to_speech, google/generate_content - google/imagen, google/veo, mistral/chat, openai/audio - openai/images, openai/responses, openai/videos, xai/responses This eliminates ~50 type: ignore comments across provider clients while maintaining the same runtime behavior through MRO chaining. --- .../src/celeste_anthropic/messages/client.py | 15 ++--- .../bfl/src/celeste_bfl/images/client.py | 19 +++--- .../src/celeste_byteplus/images/client.py | 13 +++-- .../src/celeste_byteplus/videos/client.py | 13 +++-- .../cohere/src/celeste_cohere/chat/client.py | 17 +++--- .../text_to_speech/client.py | 15 ++--- .../celeste_google/generate_content/client.py | 17 +++--- .../src/celeste_google/imagen/client.py | 11 ++-- .../google/src/celeste_google/veo/client.py | 23 ++++---- .../src/celeste_mistral/chat/client.py | 17 +++--- .../openai/src/celeste_openai/audio/client.py | 9 +-- .../src/celeste_openai/images/client.py | 19 +++--- .../src/celeste_openai/responses/client.py | 17 +++--- .../src/celeste_openai/videos/client.py | 25 ++++---- .../xai/src/celeste_xai/responses/client.py | 17 +++--- src/celeste/client.py | 58 ++++++++++++++++++- 16 files changed, 187 insertions(+), 118 deletions(-) diff --git a/packages/providers/anthropic/src/celeste_anthropic/messages/client.py b/packages/providers/anthropic/src/celeste_anthropic/messages/client.py index 8d442aba..cab5b1b7 100644 --- a/packages/providers/anthropic/src/celeste_anthropic/messages/client.py +++ b/packages/providers/anthropic/src/celeste_anthropic/messages/client.py @@ -5,6 +5,7 @@ import httpx +from celeste.client import APIMixin from celeste.core import UsageField from celeste.io import FinishReason from celeste.mime_types import ApplicationMimeType @@ -12,7 +13,7 @@ from . import config -class AnthropicMessagesClient: +class AnthropicMessagesClient(APIMixin): """Mixin for Anthropic Messages API capabilities. Provides shared implementation for all capabilities using the Messages API: @@ -39,8 +40,8 @@ def _build_request( **parameters: Any, ) -> Any: """Build request with Anthropic-specific defaults.""" - request = super()._build_request(inputs, **parameters) # type: ignore[misc] - request["model"] = self.model.id # type: ignore[attr-defined] + request = super()._build_request(inputs, **parameters) + request["model"] = self.model.id # Apply max_tokens default if not set (Anthropic requires it) if "max_tokens" not in request: @@ -53,7 +54,7 @@ def _build_headers(self, request_body: dict[str, Any]) -> dict[str, str]: beta_features: list[str] = request_body.pop("_beta_features", []) headers: dict[str, str] = { - **self.auth.get_headers(), # type: ignore[attr-defined] + **self.auth.get_headers(), config.HEADER_ANTHROPIC_VERSION: config.ANTHROPIC_VERSION, "Content-Type": ApplicationMimeType.JSON, } @@ -75,7 +76,7 @@ async def _make_request( """Make HTTP request to Anthropic Messages API endpoint.""" headers = self._build_headers(request_body) - return await self.http_client.post( # type: ignore[attr-defined,no-any-return] + return await self.http_client.post( f"{config.BASE_URL}{config.AnthropicMessagesEndpoint.CREATE_MESSAGE}", headers=headers, json_body=request_body, @@ -90,7 +91,7 @@ def _make_stream_request( request_body["stream"] = True headers = self._build_headers(request_body) - return self.http_client.stream_post( # type: ignore[attr-defined,no-any-return] + return self.http_client.stream_post( f"{config.BASE_URL}{config.AnthropicMessagesEndpoint.CREATE_MESSAGE}", headers=headers, json_body=request_body, @@ -147,7 +148,7 @@ def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: filtered_data = { k: v for k, v in response_data.items() if k not in content_fields } - return super()._build_metadata(filtered_data) # type: ignore[misc,no-any-return] + return super()._build_metadata(filtered_data) __all__ = ["AnthropicMessagesClient"] diff --git a/packages/providers/bfl/src/celeste_bfl/images/client.py b/packages/providers/bfl/src/celeste_bfl/images/client.py index b417d7e1..c9bff5cf 100644 --- a/packages/providers/bfl/src/celeste_bfl/images/client.py +++ b/packages/providers/bfl/src/celeste_bfl/images/client.py @@ -6,6 +6,7 @@ import httpx +from celeste.client import APIMixin from celeste.core import UsageField from celeste.io import FinishReason from celeste.mime_types import ApplicationMimeType @@ -13,7 +14,7 @@ from . import config -class BFLImagesClient: +class BFLImagesClient(APIMixin): """Mixin for BFL Images API operations. Provides shared implementation: @@ -44,30 +45,30 @@ async def _make_request( 2. Poll polling_url until Ready/Failed 3. Return response with _submit_metadata for usage parsing """ - auth_headers = self.auth.get_headers() # type: ignore[attr-defined] + auth_headers = self.auth.get_headers() headers = { **auth_headers, "Content-Type": ApplicationMimeType.JSON, "Accept": ApplicationMimeType.JSON, } - endpoint = config.BFLImagesEndpoint.CREATE_IMAGE.format(model_id=self.model.id) # type: ignore[attr-defined] + endpoint = config.BFLImagesEndpoint.CREATE_IMAGE.format(model_id=self.model.id) # Phase 1: Submit job - submit_response = await self.http_client.post( # type: ignore[attr-defined] + submit_response = await self.http_client.post( f"{config.BASE_URL}{endpoint}", headers=headers, json_body=request_body, ) if submit_response.status_code != 200: - return submit_response # type: ignore[no-any-return] + return submit_response submit_data = submit_response.json() polling_url = submit_data.get("polling_url") if not polling_url: - msg = f"No polling_url in {self.provider} response" # type: ignore[attr-defined] + msg = f"No polling_url in {self.provider} response" raise ValueError(msg) # Phase 2: Poll for completion @@ -80,16 +81,16 @@ async def _make_request( while True: elapsed = time.monotonic() - start_time if elapsed >= config.POLLING_TIMEOUT: - msg = f"{self.provider} polling timed out after {config.POLLING_TIMEOUT} seconds" # type: ignore[attr-defined] + msg = f"{self.provider} polling timed out after {config.POLLING_TIMEOUT} seconds" raise TimeoutError(msg) - poll_response = await self.http_client.get( # type: ignore[attr-defined] + poll_response = await self.http_client.get( polling_url, headers=poll_headers, ) if poll_response.status_code != 200: - return poll_response # type: ignore[no-any-return] + return poll_response poll_data = poll_response.json() status = poll_data.get("status") diff --git a/packages/providers/byteplus/src/celeste_byteplus/images/client.py b/packages/providers/byteplus/src/celeste_byteplus/images/client.py index 411bfea5..164e42e0 100644 --- a/packages/providers/byteplus/src/celeste_byteplus/images/client.py +++ b/packages/providers/byteplus/src/celeste_byteplus/images/client.py @@ -5,6 +5,7 @@ import httpx +from celeste.client import APIMixin from celeste.core import UsageField from celeste.io import FinishReason from celeste.mime_types import ApplicationMimeType @@ -12,7 +13,7 @@ from . import config -class BytePlusImagesClient: +class BytePlusImagesClient(APIMixin): """Mixin for BytePlus Images API capabilities. Provides shared implementation for all capabilities using the Images API: @@ -39,11 +40,11 @@ async def _make_request( request_body["stream"] = False headers = { - **self.auth.get_headers(), # type: ignore[attr-defined] + **self.auth.get_headers(), "Content-Type": ApplicationMimeType.JSON, } - return await self.http_client.post( # type: ignore[attr-defined,no-any-return] + return await self.http_client.post( f"{config.BASE_URL}{config.BytePlusImagesEndpoint.CREATE_IMAGE}", headers=headers, json_body=request_body, @@ -58,11 +59,11 @@ def _make_stream_request( request_body["stream"] = True headers = { - **self.auth.get_headers(), # type: ignore[attr-defined] + **self.auth.get_headers(), "Content-Type": ApplicationMimeType.JSON, } - return self.http_client.stream_post( # type: ignore[attr-defined,no-any-return] + return self.http_client.stream_post( f"{config.BASE_URL}{config.BytePlusImagesEndpoint.CREATE_IMAGE}", headers=headers, json_body=request_body, @@ -114,7 +115,7 @@ def _build_metadata(self, response_data: dict[str, Any]) -> Any: filtered_data = { k: v for k, v in response_data.items() if k not in content_fields } - metadata = super()._build_metadata(filtered_data) # type: ignore[misc] + metadata = super()._build_metadata(filtered_data) # Add provider-specific parsed fields seed = response_data.get("seed") diff --git a/packages/providers/byteplus/src/celeste_byteplus/videos/client.py b/packages/providers/byteplus/src/celeste_byteplus/videos/client.py index 8103db5c..3cf12b9d 100644 --- a/packages/providers/byteplus/src/celeste_byteplus/videos/client.py +++ b/packages/providers/byteplus/src/celeste_byteplus/videos/client.py @@ -7,6 +7,7 @@ import httpx +from celeste.client import APIMixin from celeste.core import UsageField from celeste.io import FinishReason from celeste.mime_types import ApplicationMimeType @@ -16,7 +17,7 @@ logger = logging.getLogger(__name__) -class BytePlusVideosClient: +class BytePlusVideosClient(APIMixin): """Mixin for BytePlus ModelArk Videos API with async polling. Provides shared implementation: @@ -46,7 +47,7 @@ async def _make_request( 2. Poll CONTENT_STATUS endpoint until succeeded/failed/canceled 3. Return response with final status data """ - auth_headers = self.auth.get_headers() # type: ignore[attr-defined] + auth_headers = self.auth.get_headers() headers = { **auth_headers, "Content-Type": ApplicationMimeType.JSON, @@ -54,14 +55,14 @@ async def _make_request( # Phase 1: Submit job logger.debug("Submitting video generation task to BytePlus") - submit_response = await self.http_client.post( # type: ignore[attr-defined] + submit_response = await self.http_client.post( f"{config.BASE_URL}{config.BytePlusVideosEndpoint.CREATE_VIDEO}", headers=headers, json_body=request_body, ) if submit_response.status_code != 200: - return submit_response # type: ignore[no-any-return] + return submit_response submit_data = submit_response.json() task_id = submit_data["id"] @@ -82,13 +83,13 @@ async def _make_request( status_url = f"{config.BASE_URL}{config.BytePlusVideosEndpoint.GET_VIDEO_STATUS.format(task_id=task_id)}" logger.debug(f"Polling BytePlus task status: {task_id}") - status_response = await self.http_client.get( # type: ignore[attr-defined] + status_response = await self.http_client.get( status_url, headers=headers, ) if status_response.status_code != 200: - return status_response # type: ignore[no-any-return] + return status_response status_data = status_response.json() status = status_data.get("status") diff --git a/packages/providers/cohere/src/celeste_cohere/chat/client.py b/packages/providers/cohere/src/celeste_cohere/chat/client.py index 5ee9b422..e3e4a3da 100644 --- a/packages/providers/cohere/src/celeste_cohere/chat/client.py +++ b/packages/providers/cohere/src/celeste_cohere/chat/client.py @@ -5,6 +5,7 @@ import httpx +from celeste.client import APIMixin from celeste.core import UsageField from celeste.io import FinishReason from celeste.mime_types import ApplicationMimeType @@ -12,7 +13,7 @@ from . import config -class CohereChatClient: +class CohereChatClient(APIMixin): """Mixin for Cohere Chat API capabilities. Provides shared implementation for all capabilities using the Chat API: @@ -37,14 +38,14 @@ async def _make_request( **parameters: Any, ) -> httpx.Response: """Make HTTP request to Cohere Chat API endpoint.""" - request_body["model"] = self.model.id # type: ignore[attr-defined] + request_body["model"] = self.model.id headers = { - **self.auth.get_headers(), # type: ignore[attr-defined] + **self.auth.get_headers(), "Content-Type": ApplicationMimeType.JSON, } - return await self.http_client.post( # type: ignore[attr-defined,no-any-return] + return await self.http_client.post( f"{config.BASE_URL}{config.CohereChatEndpoint.CREATE_CHAT}", headers=headers, json_body=request_body, @@ -56,15 +57,15 @@ def _make_stream_request( **parameters: Any, ) -> AsyncIterator[dict[str, Any]]: """Make streaming request to Cohere Chat API endpoint.""" - request_body["model"] = self.model.id # type: ignore[attr-defined] + request_body["model"] = self.model.id request_body["stream"] = True headers = { - **self.auth.get_headers(), # type: ignore[attr-defined] + **self.auth.get_headers(), "Content-Type": ApplicationMimeType.JSON, } - return self.http_client.stream_post( # type: ignore[attr-defined,no-any-return] + return self.http_client.stream_post( f"{config.BASE_URL}{config.CohereChatEndpoint.CREATE_CHAT}", headers=headers, json_body=request_body, @@ -113,7 +114,7 @@ def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: filtered_data = { k: v for k, v in response_data.items() if k not in content_fields } - return super()._build_metadata(filtered_data) # type: ignore[misc,no-any-return] + return super()._build_metadata(filtered_data) __all__ = ["CohereChatClient"] diff --git a/packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/client.py b/packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/client.py index d3f67445..e9d1e62f 100644 --- a/packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/client.py +++ b/packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/client.py @@ -5,12 +5,13 @@ import httpx +from celeste.client import APIMixin from celeste.mime_types import ApplicationMimeType, AudioMimeType from . import config -class ElevenLabsTextToSpeechClient: +class ElevenLabsTextToSpeechClient(APIMixin): """Mixin for ElevenLabs Text-to-Speech API. Provides shared implementation for speech generation: @@ -44,7 +45,7 @@ async def _make_request( voice_id = parameters.get("voice", config.DEFAULT_VOICE_ID) # Set model_id - request_body["model_id"] = self.model.id # type: ignore[attr-defined] + request_body["model_id"] = self.model.id # Build URL with voice_id in path endpoint = config.ElevenLabsTextToSpeechEndpoint.CREATE_SPEECH.format( @@ -52,11 +53,11 @@ async def _make_request( ) headers = { - **self.auth.get_headers(), # type: ignore[attr-defined] + **self.auth.get_headers(), "Content-Type": ApplicationMimeType.JSON, } - return await self.http_client.post( # type: ignore[attr-defined,no-any-return] + return await self.http_client.post( f"{config.BASE_URL}{endpoint}", headers=headers, json_body=request_body, @@ -78,7 +79,7 @@ def _make_stream_request( voice_id = parameters.get("voice", config.DEFAULT_VOICE_ID) # Set model_id - request_body["model_id"] = self.model.id # type: ignore[attr-defined] + request_body["model_id"] = self.model.id # Build URL with voice_id in path endpoint = config.ElevenLabsTextToSpeechEndpoint.STREAM_SPEECH.format( @@ -86,7 +87,7 @@ def _make_stream_request( ) headers = { - **self.auth.get_headers(), # type: ignore[attr-defined] + **self.auth.get_headers(), "Content-Type": ApplicationMimeType.JSON, } @@ -106,7 +107,7 @@ async def _stream_binary_audio( Wraps httpx streaming to yield dicts compatible with Stream interface. """ - client = await self.http_client._get_client() # type: ignore[attr-defined] + client = await self.http_client._get_client() async with client.stream( "POST", diff --git a/packages/providers/google/src/celeste_google/generate_content/client.py b/packages/providers/google/src/celeste_google/generate_content/client.py index a5a50418..0ac3426f 100644 --- a/packages/providers/google/src/celeste_google/generate_content/client.py +++ b/packages/providers/google/src/celeste_google/generate_content/client.py @@ -5,6 +5,7 @@ import httpx +from celeste.client import APIMixin from celeste.core import UsageField from celeste.io import FinishReason from celeste.mime_types import ApplicationMimeType @@ -12,7 +13,7 @@ from . import config -class GoogleGenerateContentClient: +class GoogleGenerateContentClient(APIMixin): """Mixin for GenerateContent API capabilities. Provides shared implementation for all capabilities using the GenerateContent API: @@ -40,13 +41,13 @@ async def _make_request( ) -> httpx.Response: """Make HTTP request to generateContent endpoint.""" endpoint = config.GoogleGenerateContentEndpoint.GENERATE_CONTENT.format( - model_id=self.model.id # type: ignore[attr-defined] + model_id=self.model.id ) headers = { - **self.auth.get_headers(), # type: ignore[attr-defined] + **self.auth.get_headers(), "Content-Type": ApplicationMimeType.JSON, } - return await self.http_client.post( # type: ignore[attr-defined,no-any-return] + return await self.http_client.post( f"{config.BASE_URL}{endpoint}", headers=headers, json_body=request_body, @@ -59,13 +60,13 @@ def _make_stream_request( ) -> AsyncIterator[dict[str, Any]]: """Make streaming request to streamGenerateContent endpoint.""" endpoint = config.GoogleGenerateContentEndpoint.STREAM_GENERATE_CONTENT.format( - model_id=self.model.id # type: ignore[attr-defined] + model_id=self.model.id ) headers = { - **self.auth.get_headers(), # type: ignore[attr-defined] + **self.auth.get_headers(), "Content-Type": ApplicationMimeType.JSON, } - return self.http_client.stream_post( # type: ignore[attr-defined,no-any-return] + return self.http_client.stream_post( f"{config.BASE_URL}{endpoint}", headers=headers, json_body=request_body, @@ -118,7 +119,7 @@ def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: filtered_data = { k: v for k, v in response_data.items() if k not in content_fields } - return super()._build_metadata(filtered_data) # type: ignore[misc,no-any-return] + return super()._build_metadata(filtered_data) __all__ = ["GoogleGenerateContentClient"] diff --git a/packages/providers/google/src/celeste_google/imagen/client.py b/packages/providers/google/src/celeste_google/imagen/client.py index 5bbd9474..ae91eb2d 100644 --- a/packages/providers/google/src/celeste_google/imagen/client.py +++ b/packages/providers/google/src/celeste_google/imagen/client.py @@ -4,6 +4,7 @@ import httpx +from celeste.client import APIMixin from celeste.core import UsageField from celeste.io import FinishReason from celeste.mime_types import ApplicationMimeType @@ -11,7 +12,7 @@ from . import config -class GoogleImagenClient: +class GoogleImagenClient(APIMixin): """Mixin for Imagen API capabilities. Provides shared implementation for capabilities using the Imagen API: @@ -37,13 +38,13 @@ async def _make_request( ) -> httpx.Response: """Make HTTP request to Imagen :predict endpoint.""" endpoint = config.GoogleImagenEndpoint.CREATE_IMAGE.format( - model_id=self.model.id # type: ignore[attr-defined] + model_id=self.model.id ) headers = { - **self.auth.get_headers(), # type: ignore[attr-defined] + **self.auth.get_headers(), "Content-Type": ApplicationMimeType.JSON, } - return await self.http_client.post( # type: ignore[attr-defined,no-any-return] + return await self.http_client.post( f"{config.BASE_URL}{endpoint}", headers=headers, json_body=request_body, @@ -90,7 +91,7 @@ def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: filtered_data = { k: v for k, v in response_data.items() if k not in content_fields } - return super()._build_metadata(filtered_data) # type: ignore[misc,no-any-return] + return super()._build_metadata(filtered_data) __all__ = ["GoogleImagenClient"] diff --git a/packages/providers/google/src/celeste_google/veo/client.py b/packages/providers/google/src/celeste_google/veo/client.py index 52c6c215..a48d590c 100644 --- a/packages/providers/google/src/celeste_google/veo/client.py +++ b/packages/providers/google/src/celeste_google/veo/client.py @@ -7,6 +7,7 @@ import httpx +from celeste.client import APIMixin from celeste.mime_types import ApplicationMimeType from . import config @@ -14,7 +15,7 @@ logger = logging.getLogger(__name__) -class GoogleVeoClient: +class GoogleVeoClient(APIMixin): """Mixin for Veo API capabilities. Provides shared implementation for video generation using the Veo API: @@ -39,25 +40,25 @@ async def _make_request( **parameters: Any, ) -> httpx.Response: """Make HTTP request with async polling for Veo video generation.""" - model_id = self.model.id # type: ignore[attr-defined] + model_id = self.model.id endpoint = config.GoogleVeoEndpoint.CREATE_VIDEO.format(model_id=model_id) url = f"{config.BASE_URL}{endpoint}" - auth_headers = self.auth.get_headers() # type: ignore[attr-defined] + auth_headers = self.auth.get_headers() headers = { **auth_headers, "Content-Type": ApplicationMimeType.JSON, } logger.info(f"Initiating video generation with model {model_id}") - response = await self.http_client.post( # type: ignore[attr-defined] + response = await self.http_client.post( url, headers=headers, json_body=request_body, timeout=config.DEFAULT_TIMEOUT, ) - self._handle_error_response(response) # type: ignore[attr-defined] + self._handle_error_response(response) operation_data = response.json() operation_name = operation_data["name"] @@ -70,13 +71,13 @@ async def _make_request( await asyncio.sleep(config.POLL_INTERVAL) logger.debug(f"Polling operation status: {operation_name}") - poll_response = await self.http_client.get( # type: ignore[attr-defined] + poll_response = await self.http_client.get( poll_url, headers=poll_headers, timeout=config.DEFAULT_TIMEOUT, ) - self._handle_error_response(poll_response) # type: ignore[attr-defined] + self._handle_error_response(poll_response) operation_data = poll_response.json() if operation_data.get("done"): @@ -140,17 +141,17 @@ async def download_content(self, url: str) -> bytes: logger.info(f"Downloading video from: {download_url}") - headers = self.auth.get_headers() # type: ignore[attr-defined] + headers = self.auth.get_headers() - response = await self.http_client.get( # type: ignore[attr-defined] + response = await self.http_client.get( download_url, headers=headers, timeout=config.DEFAULT_TIMEOUT, follow_redirects=True, ) - self._handle_error_response(response) # type: ignore[attr-defined] - return response.content # type: ignore[no-any-return] + self._handle_error_response(response) + return response.content __all__ = ["GoogleVeoClient"] diff --git a/packages/providers/mistral/src/celeste_mistral/chat/client.py b/packages/providers/mistral/src/celeste_mistral/chat/client.py index d20eabdd..6cc026cc 100644 --- a/packages/providers/mistral/src/celeste_mistral/chat/client.py +++ b/packages/providers/mistral/src/celeste_mistral/chat/client.py @@ -5,6 +5,7 @@ import httpx +from celeste.client import APIMixin from celeste.core import UsageField from celeste.io import FinishReason from celeste.mime_types import ApplicationMimeType @@ -12,7 +13,7 @@ from . import config -class MistralChatClient: +class MistralChatClient(APIMixin): """Mixin for Mistral Chat API capabilities. Provides shared implementation for chat-based capabilities: @@ -35,14 +36,14 @@ async def _make_request( **parameters: Any, ) -> httpx.Response: """Make HTTP request to Mistral Chat API.""" - request_body["model"] = self.model.id # type: ignore[attr-defined] + request_body["model"] = self.model.id headers = { - **self.auth.get_headers(), # type: ignore[attr-defined] + **self.auth.get_headers(), "Content-Type": ApplicationMimeType.JSON, } - return await self.http_client.post( # type: ignore[attr-defined,no-any-return] + return await self.http_client.post( f"{config.BASE_URL}{config.MistralChatEndpoint.CREATE_CHAT_COMPLETION}", headers=headers, json_body=request_body, @@ -54,15 +55,15 @@ def _make_stream_request( **parameters: Any, ) -> AsyncIterator[dict[str, Any]]: """Make HTTP streaming request to Mistral Chat API.""" - request_body["model"] = self.model.id # type: ignore[attr-defined] + request_body["model"] = self.model.id request_body["stream"] = True headers = { - **self.auth.get_headers(), # type: ignore[attr-defined] + **self.auth.get_headers(), "Content-Type": ApplicationMimeType.JSON, } - return self.http_client.stream_post( # type: ignore[attr-defined,no-any-return] + return self.http_client.stream_post( f"{config.BASE_URL}{config.MistralChatEndpoint.CREATE_CHAT_COMPLETION}", headers=headers, json_body=request_body, @@ -108,7 +109,7 @@ def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: filtered_data = { k: v for k, v in response_data.items() if k not in content_fields } - return super()._build_metadata(filtered_data) # type: ignore[misc,no-any-return] + return super()._build_metadata(filtered_data) __all__ = ["MistralChatClient"] diff --git a/packages/providers/openai/src/celeste_openai/audio/client.py b/packages/providers/openai/src/celeste_openai/audio/client.py index 42ff371b..01519d83 100644 --- a/packages/providers/openai/src/celeste_openai/audio/client.py +++ b/packages/providers/openai/src/celeste_openai/audio/client.py @@ -4,12 +4,13 @@ import httpx +from celeste.client import APIMixin from celeste.mime_types import ApplicationMimeType, AudioMimeType from . import config -class OpenAIAudioClient: +class OpenAIAudioClient(APIMixin): """Mixin for OpenAI Audio API speech generation. Provides shared implementation for speech generation: @@ -35,14 +36,14 @@ async def _make_request( Returns the raw response with binary audio content. """ - request_body["model"] = self.model.id # type: ignore[attr-defined] + request_body["model"] = self.model.id headers = { - **self.auth.get_headers(), # type: ignore[attr-defined] + **self.auth.get_headers(), "Content-Type": ApplicationMimeType.JSON, } - return await self.http_client.post( # type: ignore[attr-defined,no-any-return] + return await self.http_client.post( f"{config.BASE_URL}{config.OpenAIAudioEndpoint.CREATE_SPEECH}", headers=headers, json_body=request_body, diff --git a/packages/providers/openai/src/celeste_openai/images/client.py b/packages/providers/openai/src/celeste_openai/images/client.py index 0660fe81..1dce93c9 100644 --- a/packages/providers/openai/src/celeste_openai/images/client.py +++ b/packages/providers/openai/src/celeste_openai/images/client.py @@ -9,6 +9,7 @@ import httpx +from celeste.client import APIMixin from celeste.core import UsageField from celeste.io import FinishReason from celeste.mime_types import ApplicationMimeType @@ -16,7 +17,7 @@ from . import config -class OpenAIImagesClient: +class OpenAIImagesClient(APIMixin): """Mixin for OpenAI Images API image generation. Provides shared implementation for image generation: @@ -40,18 +41,18 @@ async def _make_request( **parameters: Any, ) -> httpx.Response: """Make HTTP request to OpenAI Images API generations endpoint.""" - request_body["model"] = self.model.id # type: ignore[attr-defined] + request_body["model"] = self.model.id # DALL-E 2/3 need b64_json response format - if self.model.id in ("dall-e-2", "dall-e-3"): # type: ignore[attr-defined] + if self.model.id in ("dall-e-2", "dall-e-3"): request_body.setdefault("response_format", "b64_json") headers = { - **self.auth.get_headers(), # type: ignore[attr-defined] + **self.auth.get_headers(), "Content-Type": ApplicationMimeType.JSON, } - return await self.http_client.post( # type: ignore[attr-defined,no-any-return] + return await self.http_client.post( f"{config.BASE_URL}{config.OpenAIImagesEndpoint.CREATE_IMAGE}", headers=headers, json_body=request_body, @@ -66,18 +67,18 @@ def _make_stream_request( Streaming is only supported for gpt-image-1. """ - request_body["model"] = self.model.id # type: ignore[attr-defined] + request_body["model"] = self.model.id request_body["stream"] = True if "partial_images" not in request_body: request_body["partial_images"] = 1 headers = { - **self.auth.get_headers(), # type: ignore[attr-defined] + **self.auth.get_headers(), "Content-Type": ApplicationMimeType.JSON, } - return self.http_client.stream_post( # type: ignore[attr-defined,no-any-return] + return self.http_client.stream_post( f"{config.BASE_URL}{config.OpenAIImagesEndpoint.CREATE_IMAGE}", headers=headers, json_body=request_body, @@ -126,7 +127,7 @@ def _build_metadata(self, response_data: dict[str, Any]) -> Any: k: v for k, v in response_data.items() if k not in content_fields } - metadata = super()._build_metadata(filtered_data) # type: ignore[misc] + metadata = super()._build_metadata(filtered_data) return metadata diff --git a/packages/providers/openai/src/celeste_openai/responses/client.py b/packages/providers/openai/src/celeste_openai/responses/client.py index d512d6f5..9cf296d9 100644 --- a/packages/providers/openai/src/celeste_openai/responses/client.py +++ b/packages/providers/openai/src/celeste_openai/responses/client.py @@ -5,6 +5,7 @@ import httpx +from celeste.client import APIMixin from celeste.core import UsageField from celeste.io import FinishReason from celeste.mime_types import ApplicationMimeType @@ -12,7 +13,7 @@ from . import config -class OpenAIResponsesClient: +class OpenAIResponsesClient(APIMixin): """Mixin for OpenAI Responses API capabilities. Provides shared implementation for all capabilities using the Responses API: @@ -41,14 +42,14 @@ async def _make_request( **parameters: Any, ) -> httpx.Response: """Make HTTP request to OpenAI Responses API endpoint.""" - request_body["model"] = self.model.id # type: ignore[attr-defined] + request_body["model"] = self.model.id headers = { - **self.auth.get_headers(), # type: ignore[attr-defined] + **self.auth.get_headers(), "Content-Type": ApplicationMimeType.JSON, } - return await self.http_client.post( # type: ignore[attr-defined,no-any-return] + return await self.http_client.post( f"{config.BASE_URL}{config.OpenAIResponsesEndpoint.CREATE_RESPONSE}", headers=headers, json_body=request_body, @@ -60,15 +61,15 @@ def _make_stream_request( **parameters: Any, ) -> AsyncIterator[dict[str, Any]]: """Make streaming request to OpenAI Responses API endpoint.""" - request_body["model"] = self.model.id # type: ignore[attr-defined] + request_body["model"] = self.model.id request_body["stream"] = True headers = { - **self.auth.get_headers(), # type: ignore[attr-defined] + **self.auth.get_headers(), "Content-Type": ApplicationMimeType.JSON, } - return self.http_client.stream_post( # type: ignore[attr-defined,no-any-return] + return self.http_client.stream_post( f"{config.BASE_URL}{config.OpenAIResponsesEndpoint.CREATE_RESPONSE}", headers=headers, json_body=request_body, @@ -126,7 +127,7 @@ def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: filtered_data = { k: v for k, v in response_data.items() if k not in content_fields } - return super()._build_metadata(filtered_data) # type: ignore[misc,no-any-return] + return super()._build_metadata(filtered_data) __all__ = ["OpenAIResponsesClient"] diff --git a/packages/providers/openai/src/celeste_openai/videos/client.py b/packages/providers/openai/src/celeste_openai/videos/client.py index b9a8e159..db9a7b58 100644 --- a/packages/providers/openai/src/celeste_openai/videos/client.py +++ b/packages/providers/openai/src/celeste_openai/videos/client.py @@ -12,6 +12,7 @@ import httpx +from celeste.client import APIMixin from celeste.core import UsageField from celeste.io import FinishReason from celeste.mime_types import ApplicationMimeType @@ -21,7 +22,7 @@ logger = logging.getLogger(__name__) -class OpenAIVideosClient: +class OpenAIVideosClient(APIMixin): """Mixin for OpenAI Videos API video generation. Provides shared implementation for video generation: @@ -53,10 +54,10 @@ async def _make_request( 2. Poll for completion 3. Fetch video content """ - request_body["model"] = self.model.id # type: ignore[attr-defined] + request_body["model"] = self.model.id headers = { - **self.auth.get_headers(), # type: ignore[attr-defined] + **self.auth.get_headers(), "Content-Type": ApplicationMimeType.JSON, } @@ -66,7 +67,7 @@ async def _make_request( if files: logger.info("Sending multipart request to OpenAI with input_reference") - response = await self.http_client.post_multipart( # type: ignore[attr-defined] + response = await self.http_client.post_multipart( f"{config.BASE_URL}{endpoint}", headers=headers, files=files, @@ -74,13 +75,13 @@ async def _make_request( ) else: logger.info(f"Sending request to OpenAI: {request_body}") - response = await self.http_client.post( # type: ignore[attr-defined] + response = await self.http_client.post( f"{config.BASE_URL}{endpoint}", headers=headers, json_body=request_body, ) - self._handle_error_response(response) # type: ignore[attr-defined] + self._handle_error_response(response) video_obj = response.json() video_id = video_obj["id"] @@ -88,11 +89,11 @@ async def _make_request( # Poll for completion for _ in range(config.MAX_POLLS): - status_response = await self.http_client.get( # type: ignore[attr-defined] + status_response = await self.http_client.get( f"{config.BASE_URL}{endpoint}/{video_id}", headers=headers, ) - self._handle_error_response(status_response) # type: ignore[attr-defined] + self._handle_error_response(status_response) video_obj = status_response.json() status = video_obj["status"] @@ -115,17 +116,17 @@ async def _make_request( raise TimeoutError(msg) # Fetch video content - content_response = await self.http_client.get( # type: ignore[attr-defined] + content_response = await self.http_client.get( f"{config.BASE_URL}{endpoint}/{video_id}{config.CONTENT_ENDPOINT_SUFFIX}", headers=headers, ) - self._handle_error_response(content_response) # type: ignore[attr-defined] + self._handle_error_response(content_response) video_data = content_response.content # Build normalized response response_data = { "video_data": base64.b64encode(video_data).decode("utf-8"), - "model": video_obj.get("model", self.model.id), # type: ignore[attr-defined] + "model": video_obj.get("model", self.model.id), "video_id": video_id, "seconds": video_obj.get("seconds"), "size": video_obj.get("size"), @@ -177,7 +178,7 @@ def _build_metadata(self, response_data: dict[str, Any]) -> Any: k: v for k, v in response_data.items() if k not in content_fields } - metadata = super()._build_metadata(filtered_data) # type: ignore[misc] + metadata = super()._build_metadata(filtered_data) return metadata diff --git a/packages/providers/xai/src/celeste_xai/responses/client.py b/packages/providers/xai/src/celeste_xai/responses/client.py index c3083aac..254b7c63 100644 --- a/packages/providers/xai/src/celeste_xai/responses/client.py +++ b/packages/providers/xai/src/celeste_xai/responses/client.py @@ -5,6 +5,7 @@ import httpx +from celeste.client import APIMixin from celeste.core import UsageField from celeste.io import FinishReason from celeste.mime_types import ApplicationMimeType @@ -12,7 +13,7 @@ from . import config -class XAIResponsesClient: +class XAIResponsesClient(APIMixin): """Mixin for XAI Responses API capabilities. Provides shared implementation for all capabilities using the Responses API: @@ -43,14 +44,14 @@ async def _make_request( **parameters: Any, ) -> httpx.Response: """Make HTTP request to XAI Responses endpoint.""" - request_body["model"] = self.model.id # type: ignore[attr-defined] + request_body["model"] = self.model.id headers = { - **self.auth.get_headers(), # type: ignore[attr-defined] + **self.auth.get_headers(), "Content-Type": ApplicationMimeType.JSON, } - return await self.http_client.post( # type: ignore[attr-defined,no-any-return] + return await self.http_client.post( f"{config.BASE_URL}{config.XAIResponsesEndpoint.CREATE_RESPONSE}", headers=headers, json_body=request_body, @@ -62,15 +63,15 @@ def _make_stream_request( **parameters: Any, ) -> AsyncIterator[dict[str, Any]]: """Make streaming request to XAI Responses endpoint.""" - request_body["model"] = self.model.id # type: ignore[attr-defined] + request_body["model"] = self.model.id request_body["stream"] = True headers = { - **self.auth.get_headers(), # type: ignore[attr-defined] + **self.auth.get_headers(), "Content-Type": ApplicationMimeType.JSON, } - return self.http_client.stream_post( # type: ignore[attr-defined,no-any-return] + return self.http_client.stream_post( f"{config.BASE_URL}{config.XAIResponsesEndpoint.CREATE_RESPONSE}", headers=headers, json_body=request_body, @@ -121,7 +122,7 @@ def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: filtered_data = { k: v for k, v in response_data.items() if k not in content_fields } - return super()._build_metadata(filtered_data) # type: ignore[misc,no-any-return] + return super()._build_metadata(filtered_data) __all__ = ["XAIResponsesClient"] diff --git a/src/celeste/client.py b/src/celeste/client.py index c9d5eb98..21fe8046 100644 --- a/src/celeste/client.py +++ b/src/celeste/client.py @@ -23,7 +23,61 @@ from celeste.types import StructuredOutput -class Client[In: Input, Out: Output, Params: Parameters](ABC, BaseModel): +class APIMixin(ABC): + """Abstract base for provider API mixins. + + Provider mixins inherit from this to gain type hints for Client attributes. + The actual attributes are provided by Client through multiple inheritance. + + Layering: + - HTTPClient: Low-level HTTP transport (requests, connection pooling) + - APIMixin: High-level provider API logic (endpoints, request/response formats) + - Client: Capability-specific client (text generation, image generation, etc.) + + Example: + class OpenAIResponsesClient(APIMixin): + async def _make_request(self, request_body, **parameters): + request_body["model"] = self.model.id # Type-safe! + headers = {**self.auth.get_headers(), ...} + return await self.http_client.post(...) + + class OpenAITextGenerationClient(OpenAIResponsesClient, TextGenerationClient): + pass + """ + + model: Model + auth: Authentication + provider: Provider + + @property + @abstractmethod + def http_client(self) -> HTTPClient: + """HTTP client with connection pooling for this provider.""" + ... + + def _build_request(self, inputs: Any, **parameters: Any) -> dict[str, Any]: + """Build request dict from inputs and parameters. + + Mixins override this and call super() to chain with Client._build_request(). + """ + return super()._build_request(inputs, **parameters) # type: ignore[misc,no-any-return] + + def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: + """Build metadata dict from response data. + + Mixins override this and call super() to chain with Client._build_metadata(). + """ + return super()._build_metadata(response_data) # type: ignore[misc,no-any-return] + + def _handle_error_response(self, response: httpx.Response) -> None: + """Handle error responses from provider APIs. + + Stub that calls through to Client._handle_error_response via MRO. + """ + super()._handle_error_response(response) # type: ignore[misc] + + +class Client[In: Input, Out: Output, Params: Parameters](APIMixin, BaseModel): """Base class for all capability-specific clients.""" model_config = ConfigDict(from_attributes=True) @@ -272,4 +326,4 @@ def get_client_class( return _clients[(capability, provider)] -__all__ = ["Client", "get_client_class", "register_client"] +__all__ = ["APIMixin", "Client", "get_client_class", "register_client"]