From fb6457bdd4e643c5837921ff28bc2764f546d68c Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Fri, 20 Feb 2026 17:30:07 +0100 Subject: [PATCH 1/5] refactor: extract Chat Completions protocol from 4 providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract shared /v1/chat/completions implementation from DeepSeek, Groq, Mistral, and Moonshot into protocols/chatcompletions/, following the same pattern as the OpenResponses protocol (#153). Protocol provides base client (request building, usage parsing, content extraction) and streaming (chunk parsing, metadata filtering). Providers override only what differs: DeepSeek (5-field usage, Vertex AI URL), Groq (custom endpoint path), Mistral (Vertex AI URL, thinking model streaming), Moonshot (stream_options, choice-level usage fallback). Also flattens all template directories (providers, modalities, protocols) to match the actual src/celeste/ code layout — removes the unnecessary src/celeste_{slug}/ nesting. Net ~140 lines removed across 34 files. --- .../protocols/chatcompletions/__init__.py | 6 + .../protocols/chatcompletions/client.py | 157 +++++++++++++++++ .../protocols/chatcompletions/config.py | 12 ++ .../protocols/chatcompletions/streaming.py | 82 +++++++++ src/celeste/providers/deepseek/chat/client.py | 122 +------------ .../providers/deepseek/chat/streaming.py | 61 +------ src/celeste/providers/groq/chat/client.py | 141 ++------------- src/celeste/providers/groq/chat/streaming.py | 75 +------- src/celeste/providers/mistral/chat/client.py | 136 +-------------- .../providers/mistral/chat/streaming.py | 31 +--- src/celeste/providers/moonshot/chat/client.py | 122 +------------ .../providers/moonshot/chat/streaming.py | 61 +------ .../__init__.py.template | 0 .../client.py.template | 0 .../io.py.template | 0 .../parameters.py.template | 0 .../providers/__init__.py.template | 0 .../{provider_slug}/__init__.py.template | 0 .../{provider_slug}/client.py.template | 0 .../{provider_slug}/models.py.template | 0 .../{provider_slug}/parameters.py.template | 0 .../streaming.py.template | 0 .../{protocol_slug}/__init__.py.template | 6 + .../{protocol_slug}/client.py.template | 160 ++++++++++++++++++ .../{protocol_slug}/config.py.template | 17 ++ .../{protocol_slug}/streaming.py.template | 72 ++++++++ .../__init__.py.template | 0 .../py.typed.template | 0 .../{api_slug}/__init__.py.template | 0 .../{api_slug}/client.py.template | 0 .../{api_slug}/config.py.template | 0 .../{api_slug}/parameters.py.template | 0 .../{api_slug}/streaming.py.template | 0 .../unit_tests/test_provider_api_templates.py | 4 +- 34 files changed, 563 insertions(+), 702 deletions(-) create mode 100644 src/celeste/protocols/chatcompletions/__init__.py create mode 100644 src/celeste/protocols/chatcompletions/client.py create mode 100644 src/celeste/protocols/chatcompletions/config.py create mode 100644 src/celeste/protocols/chatcompletions/streaming.py rename templates/modalities/{modality_slug}/{src/celeste_{modality_slug} => }/__init__.py.template (100%) rename templates/modalities/{modality_slug}/{src/celeste_{modality_slug} => }/client.py.template (100%) rename templates/modalities/{modality_slug}/{src/celeste_{modality_slug} => }/io.py.template (100%) rename templates/modalities/{modality_slug}/{src/celeste_{modality_slug} => }/parameters.py.template (100%) rename templates/modalities/{modality_slug}/{src/celeste_{modality_slug} => }/providers/__init__.py.template (100%) rename templates/modalities/{modality_slug}/{src/celeste_{modality_slug} => }/providers/{provider_slug}/__init__.py.template (100%) rename templates/modalities/{modality_slug}/{src/celeste_{modality_slug} => }/providers/{provider_slug}/client.py.template (100%) rename templates/modalities/{modality_slug}/{src/celeste_{modality_slug} => }/providers/{provider_slug}/models.py.template (100%) rename templates/modalities/{modality_slug}/{src/celeste_{modality_slug} => }/providers/{provider_slug}/parameters.py.template (100%) rename templates/modalities/{modality_slug}/{src/celeste_{modality_slug} => }/streaming.py.template (100%) create mode 100644 templates/protocols/{protocol_slug}/__init__.py.template create mode 100644 templates/protocols/{protocol_slug}/client.py.template create mode 100644 templates/protocols/{protocol_slug}/config.py.template create mode 100644 templates/protocols/{protocol_slug}/streaming.py.template rename templates/providers/{provider_slug}/{src/celeste_{provider_slug} => }/__init__.py.template (100%) rename templates/providers/{provider_slug}/{src/celeste_{provider_slug} => }/py.typed.template (100%) rename templates/providers/{provider_slug}/{src/celeste_{provider_slug} => }/{api_slug}/__init__.py.template (100%) rename templates/providers/{provider_slug}/{src/celeste_{provider_slug} => }/{api_slug}/client.py.template (100%) rename templates/providers/{provider_slug}/{src/celeste_{provider_slug} => }/{api_slug}/config.py.template (100%) rename templates/providers/{provider_slug}/{src/celeste_{provider_slug} => }/{api_slug}/parameters.py.template (100%) rename templates/providers/{provider_slug}/{src/celeste_{provider_slug} => }/{api_slug}/streaming.py.template (100%) diff --git a/src/celeste/protocols/chatcompletions/__init__.py b/src/celeste/protocols/chatcompletions/__init__.py new file mode 100644 index 00000000..cbb2d67f --- /dev/null +++ b/src/celeste/protocols/chatcompletions/__init__.py @@ -0,0 +1,6 @@ +"""Chat Completions protocol implementation.""" + +from .client import ChatCompletionsClient +from .streaming import ChatCompletionsStream + +__all__ = ["ChatCompletionsClient", "ChatCompletionsStream"] diff --git a/src/celeste/protocols/chatcompletions/client.py b/src/celeste/protocols/chatcompletions/client.py new file mode 100644 index 00000000..63ae0809 --- /dev/null +++ b/src/celeste/protocols/chatcompletions/client.py @@ -0,0 +1,157 @@ +"""Chat Completions protocol client.""" + +from collections.abc import AsyncIterator +from typing import Any, ClassVar + +from celeste.client import APIMixin +from celeste.core import UsageField +from celeste.io import FinishReason +from celeste.mime_types import ApplicationMimeType + +from . import config + + +class ChatCompletionsClient(APIMixin): + """Chat Completions protocol client. + + Provides shared implementation for all providers using the Chat Completions API: + - _build_url() - Build URL with provider base URL (override for Vertex AI) + - _build_request() - Add model ID and streaming flag + - _make_request() - HTTP POST to /v1/chat/completions + - _make_stream_request() - HTTP streaming to /v1/chat/completions + - map_usage_fields() - Map usage fields to unified names + - _parse_usage() - Extract usage dict from response + - _parse_content() - Extract choices array from response + - _parse_finish_reason() - Extract finish reason from response + - _build_metadata() - Filter content fields + + Providers override ClassVars and hook methods: + - _default_base_url - Provider's API base URL + - _default_chat_endpoint - Default endpoint path (override for non-standard paths) + - _build_url() - Override for Vertex AI URL routing + - map_usage_fields() - Override to add provider-specific usage fields + - _build_request() - Override to add provider-specific request fields + """ + + _default_base_url: ClassVar[str] = config.DEFAULT_BASE_URL + _default_chat_endpoint: ClassVar[str] = config.ChatCompletionsEndpoint.CREATE_CHAT + + def _build_url(self, endpoint: str, streaming: bool = False) -> str: + """Build full URL for request. + + Override for Vertex AI support. + """ + return f"{self._default_base_url}{endpoint}" + + def _build_request( + self, + inputs: Any, + extra_body: dict[str, Any] | None = None, + streaming: bool = False, + **parameters: Any, + ) -> dict[str, Any]: + """Build request with model ID and streaming flag.""" + request_body = super()._build_request( + inputs, extra_body=extra_body, streaming=streaming, **parameters + ) + request_body["model"] = self.model.id + if streaming: + request_body["stream"] = True + return request_body + + async def _make_request( + self, + request_body: dict[str, Any], + *, + endpoint: str | None = None, + **parameters: Any, + ) -> dict[str, Any]: + """Make HTTP request to Chat Completions API endpoint.""" + if endpoint is None: + endpoint = self._default_chat_endpoint + + headers = { + **self.auth.get_headers(), + "Content-Type": ApplicationMimeType.JSON, + } + + response = await self.http_client.post( + self._build_url(endpoint), + headers=headers, + json_body=request_body, + ) + self._handle_error_response(response) + data: dict[str, Any] = response.json() + return data + + def _make_stream_request( + self, + request_body: dict[str, Any], + *, + endpoint: str | None = None, + **parameters: Any, + ) -> AsyncIterator[dict[str, Any]]: + """Make streaming request to Chat Completions API endpoint.""" + if endpoint is None: + endpoint = self._default_chat_endpoint + + headers = { + **self.auth.get_headers(), + "Content-Type": ApplicationMimeType.JSON, + } + + return self.http_client.stream_post( + self._build_url(endpoint, streaming=True), + headers=headers, + json_body=request_body, + ) + + @staticmethod + def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | float | None]: + """Map Chat Completions usage fields to unified names. + + Shared by client and streaming across all capabilities. + """ + return { + UsageField.INPUT_TOKENS: usage_data.get("prompt_tokens"), + UsageField.OUTPUT_TOKENS: usage_data.get("completion_tokens"), + UsageField.TOTAL_TOKENS: usage_data.get("total_tokens"), + } + + def _parse_usage( + self, response_data: dict[str, Any] + ) -> dict[str, int | float | None]: + """Extract usage data from Chat Completions API response.""" + usage_data = response_data.get("usage", {}) + return ChatCompletionsClient.map_usage_fields(usage_data) + + def _parse_content(self, response_data: dict[str, Any]) -> Any: + """Parse choices array from Chat Completions API response. + + Returns raw choices array that modality clients extract from. + """ + choices = response_data.get("choices", []) + if not choices: + msg = "No choices in response" + raise ValueError(msg) + return choices + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> FinishReason: + """Extract finish reason from Chat Completions API response.""" + choices = response_data.get("choices", []) + if not choices: + reason = None + else: + reason = choices[0].get("finish_reason") + return FinishReason(reason=reason) + + def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: + """Build metadata dictionary, filtering out content fields.""" + content_fields = {"choices"} + filtered_data = { + k: v for k, v in response_data.items() if k not in content_fields + } + return super()._build_metadata(filtered_data) + + +__all__ = ["ChatCompletionsClient"] diff --git a/src/celeste/protocols/chatcompletions/config.py b/src/celeste/protocols/chatcompletions/config.py new file mode 100644 index 00000000..e83d978d --- /dev/null +++ b/src/celeste/protocols/chatcompletions/config.py @@ -0,0 +1,12 @@ +"""Chat Completions protocol endpoint configuration.""" + +from enum import StrEnum + + +class ChatCompletionsEndpoint(StrEnum): + """Endpoints for the Chat Completions protocol.""" + + CREATE_CHAT = "/v1/chat/completions" + + +DEFAULT_BASE_URL = "http://localhost:8000" diff --git a/src/celeste/protocols/chatcompletions/streaming.py b/src/celeste/protocols/chatcompletions/streaming.py new file mode 100644 index 00000000..1de35539 --- /dev/null +++ b/src/celeste/protocols/chatcompletions/streaming.py @@ -0,0 +1,82 @@ +"""Chat Completions protocol SSE parsing for streaming.""" + +from typing import Any + +from celeste.io import FinishReason + +from .client import ChatCompletionsClient + + +class ChatCompletionsStream: + """Chat Completions protocol SSE parsing mixin. + + Provides shared implementation for streaming parsing (protocol level): + - _parse_chunk_content(event_data) - Extract content from SSE event + - _parse_chunk_usage(event_data) - Extract and normalize usage from SSE event + - _parse_chunk_finish_reason(event_data) - Extract finish reason from SSE event + - _build_stream_metadata(raw_events) - Filter content-only events + + Modality streams call super() methods which resolve to this via MRO. + """ + + def _parse_chunk_content(self, event_data: dict[str, Any]) -> str | None: + """Extract content from SSE event.""" + object_type = event_data.get("object") + if object_type != "chat.completion.chunk": + return None + + choices = event_data.get("choices", []) + if not choices: + return None + + first_choice = choices[0] + if not isinstance(first_choice, dict): + return None + + delta = first_choice.get("delta", {}) + if not isinstance(delta, dict): + return None + + return delta.get("content") + + def _parse_chunk_usage( + self, event_data: dict[str, Any] + ) -> dict[str, int | float | None] | None: + """Extract and normalize usage from SSE event.""" + usage_data = event_data.get("usage") + if isinstance(usage_data, dict): + return ChatCompletionsClient.map_usage_fields(usage_data) + + return None + + def _parse_chunk_finish_reason( + self, event_data: dict[str, Any] + ) -> FinishReason | None: + """Extract finish reason from SSE event.""" + object_type = event_data.get("object") + if object_type != "chat.completion.chunk": + return None + + choices = event_data.get("choices", []) + if not choices: + return None + + first_choice = choices[0] + if not isinstance(first_choice, dict): + return None + + finish_reason = first_choice.get("finish_reason") + if finish_reason: + return FinishReason(reason=finish_reason) + + return None + + def _build_stream_metadata( + self, raw_events: list[dict[str, Any]] + ) -> dict[str, Any]: + """Filter content-only events for size efficiency.""" + filtered = [event for event in raw_events if event.get("usage")] + return super()._build_stream_metadata(filtered) # type: ignore[misc, no-any-return] + + +__all__ = ["ChatCompletionsStream"] diff --git a/src/celeste/providers/deepseek/chat/client.py b/src/celeste/providers/deepseek/chat/client.py index 6d86adeb..9d12ab87 100644 --- a/src/celeste/providers/deepseek/chat/client.py +++ b/src/celeste/providers/deepseek/chat/client.py @@ -1,37 +1,24 @@ """DeepSeek Chat API client mixin.""" -from collections.abc import AsyncIterator -from typing import Any +from typing import Any, ClassVar -from celeste.client import APIMixin from celeste.core import UsageField -from celeste.io import FinishReason -from celeste.mime_types import ApplicationMimeType +from celeste.protocols.chatcompletions import ChatCompletionsClient from celeste.providers.google.auth import GoogleADC from . import config -class DeepSeekChatClient(APIMixin): +class DeepSeekChatClient(ChatCompletionsClient): """Mixin for DeepSeek Chat API capabilities. - Provides shared implementation for all capabilities using the Chat API: - - _make_request() - HTTP POST to /v1/chat/completions - - _make_stream_request() - HTTP streaming to /v1/chat/completions - - _parse_usage() - Extract usage dict from response - - _parse_content() - Extract choices array from response - - _parse_finish_reason() - Extract finish reason from response - - _build_metadata() - Filter content fields - - Usage: - class DeepSeekTextGenerationClient(DeepSeekChatClient, TextGenerationClient): - def _parse_content(self, response_data, **parameters): - choices = super()._parse_content(response_data) - message = choices[0].get("message", {}) - content = message.get("content") or "" - return self._transform_output(content, **parameters) + Inherits shared Chat Completions implementation. Overrides: + - _build_url() - Vertex AI endpoint routing + - map_usage_fields() - Extended usage fields (cached_tokens, reasoning_tokens) """ + _default_base_url: ClassVar[str] = config.BASE_URL + def _get_vertex_endpoint(self, deepseek_endpoint: str) -> str: """Map DeepSeek endpoint to Vertex AI endpoint.""" mapping: dict[str, str] = { @@ -42,75 +29,12 @@ def _get_vertex_endpoint(self, deepseek_endpoint: str) -> str: raise ValueError(f"No Vertex AI endpoint mapping for: {deepseek_endpoint}") return vertex_endpoint - def _build_url(self, endpoint: str) -> str: + def _build_url(self, endpoint: str, streaming: bool = False) -> str: """Build full URL based on auth type.""" if isinstance(self.auth, GoogleADC): return self.auth.build_url(self._get_vertex_endpoint(endpoint)) return f"{config.BASE_URL}{endpoint}" - def _build_request( - self, - inputs: Any, - extra_body: dict[str, Any] | None = None, - streaming: bool = False, - **parameters: Any, - ) -> dict[str, Any]: - """Build request with model ID and streaming flag.""" - request_body = super()._build_request( - inputs, extra_body=extra_body, streaming=streaming, **parameters - ) - request_body["model"] = self.model.id - if streaming: - request_body["stream"] = True - return request_body - - async def _make_request( - self, - request_body: dict[str, Any], - *, - endpoint: str | None = None, - **parameters: Any, - ) -> dict[str, Any]: - """Make HTTP request to DeepSeek Chat API endpoint.""" - if endpoint is None: - endpoint = config.DeepSeekChatEndpoint.CREATE_CHAT - - headers = { - **self.auth.get_headers(), - "Content-Type": ApplicationMimeType.JSON, - } - - response = await self.http_client.post( - self._build_url(endpoint), - headers=headers, - json_body=request_body, - ) - self._handle_error_response(response) - data: dict[str, Any] = response.json() - return data - - def _make_stream_request( - self, - request_body: dict[str, Any], - *, - endpoint: str | None = None, - **parameters: Any, - ) -> AsyncIterator[dict[str, Any]]: - """Make streaming request to DeepSeek Chat API endpoint.""" - if endpoint is None: - endpoint = config.DeepSeekChatEndpoint.CREATE_CHAT - - headers = { - **self.auth.get_headers(), - "Content-Type": ApplicationMimeType.JSON, - } - - return self.http_client.stream_post( - self._build_url(endpoint), - headers=headers, - json_body=request_body, - ) - @staticmethod def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | float | None]: """Map DeepSeek usage fields to unified names. @@ -136,33 +60,5 @@ def _parse_usage( usage_data = response_data.get("usage", {}) return DeepSeekChatClient.map_usage_fields(usage_data) - def _parse_content(self, response_data: dict[str, Any]) -> Any: - """Parse choices array from Chat API response. - - Returns raw choices array that capability clients extract from. - """ - choices = response_data.get("choices", []) - if not choices: - msg = "No choices in response" - raise ValueError(msg) - return choices - - def _parse_finish_reason(self, response_data: dict[str, Any]) -> FinishReason: - """Extract finish reason from Chat API response.""" - choices = response_data.get("choices", []) - if not choices: - reason = None - else: - reason = choices[0].get("finish_reason") - return FinishReason(reason=reason) - - def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: - """Build metadata dictionary, filtering out content fields.""" - content_fields = {"choices"} - filtered_data = { - k: v for k, v in response_data.items() if k not in content_fields - } - return super()._build_metadata(filtered_data) - __all__ = ["DeepSeekChatClient"] diff --git a/src/celeste/providers/deepseek/chat/streaming.py b/src/celeste/providers/deepseek/chat/streaming.py index 586729b3..79d9a112 100644 --- a/src/celeste/providers/deepseek/chat/streaming.py +++ b/src/celeste/providers/deepseek/chat/streaming.py @@ -2,42 +2,18 @@ from typing import Any -from celeste.io import FinishReason +from celeste.protocols.chatcompletions import ChatCompletionsStream from .client import DeepSeekChatClient -class DeepSeekChatStream: +class DeepSeekChatStream(ChatCompletionsStream): """Mixin for Chat API SSE parsing. - Provides shared implementation for streaming parsing (provider API level): - - _parse_chunk_content(event_data) - Extract content from SSE event - - _parse_chunk_usage(event_data) - Extract and normalize usage from SSE event - - _parse_chunk_finish_reason(event_data) - Extract finish reason from SSE event - - Modality streams call super() methods which resolve to this via MRO. + Inherits shared Chat Completions streaming implementation. Overrides: + - _parse_chunk_usage() - Uses DeepSeek's extended usage fields """ - def _parse_chunk_content(self, event_data: dict[str, Any]) -> str | None: - """Extract content from SSE event.""" - object_type = event_data.get("object") - if object_type != "chat.completion.chunk": - return None - - choices = event_data.get("choices", []) - if not choices: - return None - - first_choice = choices[0] - if not isinstance(first_choice, dict): - return None - - delta = first_choice.get("delta", {}) - if not isinstance(delta, dict): - return None - - return delta.get("content") - def _parse_chunk_usage( self, event_data: dict[str, Any] ) -> dict[str, int | float | None] | None: @@ -48,34 +24,5 @@ def _parse_chunk_usage( return None - def _parse_chunk_finish_reason( - self, event_data: dict[str, Any] - ) -> FinishReason | None: - """Extract finish reason from SSE event.""" - object_type = event_data.get("object") - if object_type != "chat.completion.chunk": - return None - - choices = event_data.get("choices", []) - if not choices: - return None - - first_choice = choices[0] - if not isinstance(first_choice, dict): - return None - - finish_reason = first_choice.get("finish_reason") - if finish_reason: - return FinishReason(reason=finish_reason) - - return None - - def _build_stream_metadata( - self, raw_events: list[dict[str, Any]] - ) -> dict[str, Any]: - """Filter content-only events for size efficiency (content is in Output.content).""" - filtered = [event for event in raw_events if event.get("usage")] - return super()._build_stream_metadata(filtered) # type: ignore[misc] - __all__ = ["DeepSeekChatStream"] diff --git a/src/celeste/providers/groq/chat/client.py b/src/celeste/providers/groq/chat/client.py index 571d3b55..a66fad8e 100644 --- a/src/celeste/providers/groq/chat/client.py +++ b/src/celeste/providers/groq/chat/client.py @@ -1,145 +1,24 @@ """Groq Chat API client mixin.""" -from collections.abc import AsyncIterator -from typing import Any +from typing import ClassVar -from celeste.client import APIMixin -from celeste.core import UsageField -from celeste.io import FinishReason -from celeste.mime_types import ApplicationMimeType +from celeste.protocols.chatcompletions import ChatCompletionsClient from . import config -class GroqChatClient(APIMixin): +class GroqChatClient(ChatCompletionsClient): """Mixin for Groq Chat API capabilities. - Provides shared implementation for all capabilities using the Chat API: - - _make_request() - HTTP POST to /openai/v1/chat/completions - - _make_stream_request() - HTTP streaming to /openai/v1/chat/completions - - _parse_usage() - Extract usage dict from response - - _parse_content() - Extract choices array from response - - _parse_finish_reason() - Extract finish reason from response - - _build_metadata() - Filter content fields - - Usage: - class GroqTextGenerationClient(GroqChatClient, TextGenerationClient): - def _parse_content(self, response_data, **parameters): - choices = super()._parse_content(response_data) - message = choices[0].get("message", {}) - content = message.get("content") or "" - return self._transform_output(content, **parameters) + Inherits shared Chat Completions implementation. Only overrides: + - _default_base_url - Groq API base URL + - _default_chat_endpoint - Groq uses /openai/v1/chat/completions """ - def _build_request( - self, - inputs: Any, - extra_body: dict[str, Any] | None = None, - streaming: bool = False, - **parameters: Any, - ) -> dict[str, Any]: - """Build request with model ID and streaming flag.""" - request_body = super()._build_request( - inputs, extra_body=extra_body, streaming=streaming, **parameters - ) - request_body["model"] = self.model.id - if streaming: - request_body["stream"] = True - return request_body - - async def _make_request( - self, - request_body: dict[str, Any], - *, - endpoint: str | None = None, - **parameters: Any, - ) -> dict[str, Any]: - """Make HTTP request to Groq Chat API endpoint.""" - if endpoint is None: - endpoint = config.GroqChatEndpoint.CREATE_CHAT_COMPLETION - - headers = { - **self.auth.get_headers(), - "Content-Type": ApplicationMimeType.JSON, - } - - response = await self.http_client.post( - f"{config.BASE_URL}{endpoint}", - headers=headers, - json_body=request_body, - ) - self._handle_error_response(response) - data: dict[str, Any] = response.json() - return data - - def _make_stream_request( - self, - request_body: dict[str, Any], - *, - endpoint: str | None = None, - **parameters: Any, - ) -> AsyncIterator[dict[str, Any]]: - """Make streaming request to Groq Chat API endpoint.""" - if endpoint is None: - endpoint = config.GroqChatEndpoint.CREATE_CHAT_COMPLETION - - headers = { - **self.auth.get_headers(), - "Content-Type": ApplicationMimeType.JSON, - } - - return self.http_client.stream_post( - f"{config.BASE_URL}{endpoint}", - headers=headers, - json_body=request_body, - ) - - @staticmethod - def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | float | None]: - """Map Groq usage fields to unified names. - - Shared by client and streaming across all capabilities. - """ - return { - UsageField.INPUT_TOKENS: usage_data.get("prompt_tokens"), - UsageField.OUTPUT_TOKENS: usage_data.get("completion_tokens"), - UsageField.TOTAL_TOKENS: usage_data.get("total_tokens"), - } - - def _parse_usage( - self, response_data: dict[str, Any] - ) -> dict[str, int | float | None]: - """Extract usage data from Chat API response.""" - usage_data = response_data.get("usage", {}) - return GroqChatClient.map_usage_fields(usage_data) - - def _parse_content(self, response_data: dict[str, Any]) -> Any: - """Parse choices array from Chat API response. - - Returns raw choices array that capability clients extract from. - """ - choices = response_data.get("choices", []) - if not choices: - msg = "No choices in response" - raise ValueError(msg) - return choices - - def _parse_finish_reason(self, response_data: dict[str, Any]) -> FinishReason: - """Extract finish reason from Chat API response.""" - choices = response_data.get("choices", []) - if not choices: - reason = None - else: - reason = choices[0].get("finish_reason") - return FinishReason(reason=reason) - - def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: - """Build metadata dictionary, filtering out content fields.""" - content_fields = {"choices"} - filtered_data = { - k: v for k, v in response_data.items() if k not in content_fields - } - return super()._build_metadata(filtered_data) + _default_base_url: ClassVar[str] = config.BASE_URL + _default_chat_endpoint: ClassVar[str] = ( + config.GroqChatEndpoint.CREATE_CHAT_COMPLETION + ) __all__ = ["GroqChatClient"] diff --git a/src/celeste/providers/groq/chat/streaming.py b/src/celeste/providers/groq/chat/streaming.py index 3151a818..bd1694f5 100644 --- a/src/celeste/providers/groq/chat/streaming.py +++ b/src/celeste/providers/groq/chat/streaming.py @@ -1,81 +1,14 @@ """Groq Chat SSE parsing for streaming.""" -from typing import Any +from celeste.protocols.chatcompletions import ChatCompletionsStream -from celeste.io import FinishReason -from .client import GroqChatClient - - -class GroqChatStream: +class GroqChatStream(ChatCompletionsStream): """Mixin for Chat API SSE parsing. - Provides shared implementation for streaming parsing (provider API level): - - _parse_chunk_content(event_data) - Extract content from SSE event - - _parse_chunk_usage(event_data) - Extract and normalize usage from SSE event - - _parse_chunk_finish_reason(event_data) - Extract finish reason from SSE event - - Modality streams call super() methods which resolve to this via MRO. + Inherits shared Chat Completions streaming implementation. + No overrides needed — standard Chat Completions SSE format. """ - def _parse_chunk_content(self, event_data: dict[str, Any]) -> str | None: - """Extract content from SSE event.""" - object_type = event_data.get("object") - if object_type != "chat.completion.chunk": - return None - - choices = event_data.get("choices", []) - if not choices: - return None - - first_choice = choices[0] - if not isinstance(first_choice, dict): - return None - - delta = first_choice.get("delta", {}) - if not isinstance(delta, dict): - return None - - return delta.get("content") - - def _parse_chunk_usage( - self, event_data: dict[str, Any] - ) -> dict[str, int | float | None] | None: - """Extract and normalize usage from SSE event.""" - usage_data = event_data.get("usage") - if isinstance(usage_data, dict): - return GroqChatClient.map_usage_fields(usage_data) - - return None - - def _parse_chunk_finish_reason( - self, event_data: dict[str, Any] - ) -> FinishReason | None: - """Extract finish reason from SSE event.""" - object_type = event_data.get("object") - if object_type != "chat.completion.chunk": - return None - - choices = event_data.get("choices", []) - if not choices: - return None - - first_choice = choices[0] - if not isinstance(first_choice, dict): - return None - - finish_reason = first_choice.get("finish_reason") - if finish_reason: - return FinishReason(reason=finish_reason) - - return None - - def _build_stream_metadata( - self, raw_events: list[dict[str, Any]] - ) -> dict[str, Any]: - """Filter content-only events for size efficiency (content is in Output.content).""" - filtered = [event for event in raw_events if event.get("usage")] - return super()._build_stream_metadata(filtered) # type: ignore[misc] - __all__ = ["GroqChatStream"] diff --git a/src/celeste/providers/mistral/chat/client.py b/src/celeste/providers/mistral/chat/client.py index d1617e9e..d91292b7 100644 --- a/src/celeste/providers/mistral/chat/client.py +++ b/src/celeste/providers/mistral/chat/client.py @@ -1,37 +1,22 @@ """Mistral Chat API client mixin.""" -from collections.abc import AsyncIterator -from typing import Any +from typing import ClassVar -from celeste.client import APIMixin -from celeste.core import UsageField -from celeste.io import FinishReason -from celeste.mime_types import ApplicationMimeType +from celeste.protocols.chatcompletions import ChatCompletionsClient from celeste.providers.google.auth import GoogleADC from . import config -class MistralChatClient(APIMixin): +class MistralChatClient(ChatCompletionsClient): """Mixin for Mistral Chat API capabilities. - Provides shared implementation for chat-based capabilities: - - _make_request() - HTTP POST to /v1/chat/completions - - _make_stream_request() - SSE streaming to /v1/chat/completions - - _parse_usage() - Extract usage dict from response - - _parse_content() - Extract choices from response - - _parse_finish_reason() - Extract finish reason from response - - _build_metadata() - Filter content fields - - Usage: - class MistralTextGenerationClient(MistralChatClient, TextGenerationClient): - def _parse_content(self, response_data, **parameters): - choices = super()._parse_content(response_data) - message = choices[0].get("message", {}) - content = message.get("content") or "" - return self._transform_output(content, **parameters) + Inherits shared Chat Completions implementation. Overrides: + - _build_url() - Vertex AI endpoint routing with streaming awareness """ + _default_base_url: ClassVar[str] = config.BASE_URL + def _get_vertex_endpoint( self, mistral_endpoint: str, streaming: bool = False ) -> str: @@ -49,112 +34,5 @@ def _build_url(self, endpoint: str, streaming: bool = False) -> str: ) return f"{config.BASE_URL}{endpoint}" - def _build_request( - self, - inputs: Any, - extra_body: dict[str, Any] | None = None, - streaming: bool = False, - **parameters: Any, - ) -> dict[str, Any]: - """Build request with model ID and streaming flag.""" - request_body = super()._build_request( - inputs, extra_body=extra_body, streaming=streaming, **parameters - ) - request_body["model"] = self.model.id - if streaming: - request_body["stream"] = True - return request_body - - async def _make_request( - self, - request_body: dict[str, Any], - *, - endpoint: str | None = None, - **parameters: Any, - ) -> dict[str, Any]: - """Make HTTP request to Mistral Chat API.""" - if endpoint is None: - endpoint = config.MistralChatEndpoint.CREATE_CHAT_COMPLETION - - headers = { - **self.auth.get_headers(), - "Content-Type": ApplicationMimeType.JSON, - } - - response = await self.http_client.post( - self._build_url(endpoint), - headers=headers, - json_body=request_body, - ) - self._handle_error_response(response) - data: dict[str, Any] = response.json() - return data - - def _make_stream_request( - self, - request_body: dict[str, Any], - *, - endpoint: str | None = None, - **parameters: Any, - ) -> AsyncIterator[dict[str, Any]]: - """Make HTTP streaming request to Mistral Chat API.""" - if endpoint is None: - endpoint = config.MistralChatEndpoint.CREATE_CHAT_COMPLETION - - headers = { - **self.auth.get_headers(), - "Content-Type": ApplicationMimeType.JSON, - } - - return self.http_client.stream_post( - self._build_url(endpoint, streaming=True), - headers=headers, - json_body=request_body, - ) - - @staticmethod - def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | float | None]: - """Map Mistral usage fields to unified names. - - Shared by client and streaming across all capabilities. - """ - return { - UsageField.INPUT_TOKENS: usage_data.get("prompt_tokens"), - UsageField.OUTPUT_TOKENS: usage_data.get("completion_tokens"), - UsageField.TOTAL_TOKENS: usage_data.get("total_tokens"), - } - - def _parse_usage( - self, response_data: dict[str, Any] - ) -> dict[str, int | float | None]: - """Extract usage data from response.""" - usage_data = response_data.get("usage", {}) - return MistralChatClient.map_usage_fields(usage_data) - - def _parse_content(self, response_data: dict[str, Any]) -> Any: - """Return choices from response.""" - choices = response_data.get("choices", []) - if not choices: - msg = "No choices in response" - raise ValueError(msg) - return choices - - def _parse_finish_reason(self, response_data: dict[str, Any]) -> FinishReason: - """Extract finish reason from choices.""" - choices = response_data.get("choices", []) - if not choices: - reason = None - else: - reason = choices[0].get("finish_reason") - return FinishReason(reason=reason) - - def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: - """Build metadata dictionary, filtering out content fields.""" - content_fields = {"choices"} - filtered_data = { - k: v for k, v in response_data.items() if k not in content_fields - } - return super()._build_metadata(filtered_data) - __all__ = ["MistralChatClient"] diff --git a/src/celeste/providers/mistral/chat/streaming.py b/src/celeste/providers/mistral/chat/streaming.py index 584b7073..6c8aac10 100644 --- a/src/celeste/providers/mistral/chat/streaming.py +++ b/src/celeste/providers/mistral/chat/streaming.py @@ -3,19 +3,15 @@ from typing import Any from celeste.io import FinishReason +from celeste.protocols.chatcompletions import ChatCompletionsStream -from .client import MistralChatClient - -class MistralChatStream: +class MistralChatStream(ChatCompletionsStream): """Mixin for Chat API SSE parsing. - Provides shared implementation for streaming parsing (provider API level): - - _parse_chunk_content(event_data) - Extract content from SSE event - - _parse_chunk_usage(event_data) - Extract and normalize usage from SSE event - - _parse_chunk_finish_reason(event_data) - Extract finish reason from SSE event - - Modality streams call super() methods which resolve to this via MRO. + Inherits shared Chat Completions streaming implementation. Overrides: + - _parse_chunk_content() - No object type check, thinking model list content + - _parse_chunk_finish_reason() - No object type check """ def _parse_chunk_content(self, event_data: dict[str, Any]) -> str | None: @@ -44,16 +40,6 @@ def _parse_chunk_content(self, event_data: dict[str, Any]) -> str | None: return content_delta - def _parse_chunk_usage( - self, event_data: dict[str, Any] - ) -> dict[str, int | float | None] | None: - """Extract and normalize usage from SSE event.""" - usage_data = event_data.get("usage") - if isinstance(usage_data, dict): - return MistralChatClient.map_usage_fields(usage_data) - - return None - def _parse_chunk_finish_reason( self, event_data: dict[str, Any] ) -> FinishReason | None: @@ -72,12 +58,5 @@ def _parse_chunk_finish_reason( return None - def _build_stream_metadata( - self, raw_events: list[dict[str, Any]] - ) -> dict[str, Any]: - """Filter content-only events for size efficiency (content is in Output.content).""" - filtered = [event for event in raw_events if event.get("usage")] - return super()._build_stream_metadata(filtered) # type: ignore[misc] - __all__ = ["MistralChatStream"] diff --git a/src/celeste/providers/moonshot/chat/client.py b/src/celeste/providers/moonshot/chat/client.py index 9237ccbf..ebd31e99 100644 --- a/src/celeste/providers/moonshot/chat/client.py +++ b/src/celeste/providers/moonshot/chat/client.py @@ -1,36 +1,21 @@ """Moonshot Chat API client mixin.""" -from collections.abc import AsyncIterator -from typing import Any +from typing import Any, ClassVar -from celeste.client import APIMixin -from celeste.core import UsageField -from celeste.io import FinishReason -from celeste.mime_types import ApplicationMimeType +from celeste.protocols.chatcompletions import ChatCompletionsClient from . import config -class MoonshotChatClient(APIMixin): +class MoonshotChatClient(ChatCompletionsClient): """Mixin for Moonshot Chat API. - Provides shared implementation for chat-based capabilities: - - _make_request() - HTTP POST to /v1/chat/completions - - _make_stream_request() - SSE streaming with usage inclusion - - _parse_usage() - Extract usage from response - - _parse_content() - Extract choices from response - - _parse_finish_reason() - Extract finish_reason from choices[0] - - _build_metadata() - Filter content fields - - Usage: - class MoonshotTextGenerationClient(MoonshotChatClient, TextGenerationClient): - def _parse_content(self, response_data, **parameters): - choices = super()._parse_content(response_data) - message = choices[0].get("message", {}) - content = message.get("content") or "" - return self._transform_output(content, **parameters) + Inherits shared Chat Completions implementation. Overrides: + - _build_request() - Adds stream_options for usage inclusion """ + _default_base_url: ClassVar[str] = config.BASE_URL + def _build_request( self, inputs: Any, @@ -42,102 +27,9 @@ def _build_request( request_body = super()._build_request( inputs, extra_body=extra_body, streaming=streaming, **parameters ) - request_body["model"] = self.model.id if streaming: - request_body["stream"] = True request_body["stream_options"] = {"include_usage": True} return request_body - async def _make_request( - self, - request_body: dict[str, Any], - *, - endpoint: str | None = None, - **parameters: Any, - ) -> dict[str, Any]: - """Make HTTP request to Moonshot Chat endpoint.""" - if endpoint is None: - endpoint = config.MoonshotChatEndpoint.CREATE_CHAT - - headers = { - **self.auth.get_headers(), - "Content-Type": ApplicationMimeType.JSON, - } - - response = await self.http_client.post( - f"{config.BASE_URL}{endpoint}", - headers=headers, - json_body=request_body, - ) - self._handle_error_response(response) - data: dict[str, Any] = response.json() - return data - - def _make_stream_request( - self, - request_body: dict[str, Any], - *, - endpoint: str | None = None, - **parameters: Any, - ) -> AsyncIterator[dict[str, Any]]: - """Make HTTP streaming request returning SSE events.""" - if endpoint is None: - endpoint = config.MoonshotChatEndpoint.CREATE_CHAT - - headers = { - **self.auth.get_headers(), - "Content-Type": ApplicationMimeType.JSON, - } - - return self.http_client.stream_post( - f"{config.BASE_URL}{endpoint}", - headers=headers, - json_body=request_body, - ) - - @staticmethod - def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | float | None]: - """Map Moonshot usage fields to unified names. - - Shared by client and streaming across all capabilities. - """ - return { - UsageField.INPUT_TOKENS: usage_data.get("prompt_tokens"), - UsageField.OUTPUT_TOKENS: usage_data.get("completion_tokens"), - UsageField.TOTAL_TOKENS: usage_data.get("total_tokens"), - } - - def _parse_usage( - self, response_data: dict[str, Any] - ) -> dict[str, int | float | None]: - """Extract usage data from response.""" - usage_data = response_data.get("usage", {}) - return MoonshotChatClient.map_usage_fields(usage_data) - - def _parse_content(self, response_data: dict[str, Any]) -> Any: - """Return choices from response.""" - choices = response_data.get("choices", []) - if not choices: - msg = "No choices in response" - raise ValueError(msg) - return choices - - def _parse_finish_reason(self, response_data: dict[str, Any]) -> FinishReason: - """Extract finish reason from choices.""" - choices = response_data.get("choices", []) - if not choices: - reason = None - else: - reason = choices[0].get("finish_reason") - return FinishReason(reason=reason) - - def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: - """Build metadata dictionary, filtering out content fields.""" - content_fields = {"choices"} - filtered_data = { - k: v for k, v in response_data.items() if k not in content_fields - } - return super()._build_metadata(filtered_data) - __all__ = ["MoonshotChatClient"] diff --git a/src/celeste/providers/moonshot/chat/streaming.py b/src/celeste/providers/moonshot/chat/streaming.py index 33211514..e2e45fc6 100644 --- a/src/celeste/providers/moonshot/chat/streaming.py +++ b/src/celeste/providers/moonshot/chat/streaming.py @@ -2,42 +2,18 @@ from typing import Any -from celeste.io import FinishReason +from celeste.protocols.chatcompletions import ChatCompletionsStream from .client import MoonshotChatClient -class MoonshotChatStream: +class MoonshotChatStream(ChatCompletionsStream): """Mixin for Chat API SSE parsing. - Provides shared implementation for streaming parsing (provider API level): - - _parse_chunk_content(event_data) - Extract content from SSE event - - _parse_chunk_usage(event_data) - Extract and normalize usage from SSE event - - _parse_chunk_finish_reason(event_data) - Extract finish reason from SSE event - - Modality streams call super() methods which resolve to this via MRO. + Inherits shared Chat Completions streaming implementation. Overrides: + - _parse_chunk_usage() - Checks both top-level and choices[0] usage locations """ - def _parse_chunk_content(self, event_data: dict[str, Any]) -> str | None: - """Extract content from SSE event.""" - object_type = event_data.get("object") - if object_type != "chat.completion.chunk": - return None - - choices = event_data.get("choices", []) - if not choices: - return None - - first_choice = choices[0] - if not isinstance(first_choice, dict): - return None - - delta = first_choice.get("delta", {}) - if not isinstance(delta, dict): - return None - - return delta.get("content") - def _parse_chunk_usage( self, event_data: dict[str, Any] ) -> dict[str, int | float | None] | None: @@ -54,34 +30,5 @@ def _parse_chunk_usage( return None - def _parse_chunk_finish_reason( - self, event_data: dict[str, Any] - ) -> FinishReason | None: - """Extract finish reason from SSE event.""" - object_type = event_data.get("object") - if object_type != "chat.completion.chunk": - return None - - choices = event_data.get("choices", []) - if not choices: - return None - - first_choice = choices[0] - if not isinstance(first_choice, dict): - return None - - finish_reason = first_choice.get("finish_reason") - if finish_reason: - return FinishReason(reason=finish_reason) - - return None - - def _build_stream_metadata( - self, raw_events: list[dict[str, Any]] - ) -> dict[str, Any]: - """Filter content-only events for size efficiency (content is in Output.content).""" - filtered = [event for event in raw_events if event.get("usage")] - return super()._build_stream_metadata(filtered) # type: ignore[misc] - __all__ = ["MoonshotChatStream"] diff --git a/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/__init__.py.template b/templates/modalities/{modality_slug}/__init__.py.template similarity index 100% rename from templates/modalities/{modality_slug}/src/celeste_{modality_slug}/__init__.py.template rename to templates/modalities/{modality_slug}/__init__.py.template diff --git a/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/client.py.template b/templates/modalities/{modality_slug}/client.py.template similarity index 100% rename from templates/modalities/{modality_slug}/src/celeste_{modality_slug}/client.py.template rename to templates/modalities/{modality_slug}/client.py.template diff --git a/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/io.py.template b/templates/modalities/{modality_slug}/io.py.template similarity index 100% rename from templates/modalities/{modality_slug}/src/celeste_{modality_slug}/io.py.template rename to templates/modalities/{modality_slug}/io.py.template diff --git a/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/parameters.py.template b/templates/modalities/{modality_slug}/parameters.py.template similarity index 100% rename from templates/modalities/{modality_slug}/src/celeste_{modality_slug}/parameters.py.template rename to templates/modalities/{modality_slug}/parameters.py.template diff --git a/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/providers/__init__.py.template b/templates/modalities/{modality_slug}/providers/__init__.py.template similarity index 100% rename from templates/modalities/{modality_slug}/src/celeste_{modality_slug}/providers/__init__.py.template rename to templates/modalities/{modality_slug}/providers/__init__.py.template diff --git a/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/providers/{provider_slug}/__init__.py.template b/templates/modalities/{modality_slug}/providers/{provider_slug}/__init__.py.template similarity index 100% rename from templates/modalities/{modality_slug}/src/celeste_{modality_slug}/providers/{provider_slug}/__init__.py.template rename to templates/modalities/{modality_slug}/providers/{provider_slug}/__init__.py.template diff --git a/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/providers/{provider_slug}/client.py.template b/templates/modalities/{modality_slug}/providers/{provider_slug}/client.py.template similarity index 100% rename from templates/modalities/{modality_slug}/src/celeste_{modality_slug}/providers/{provider_slug}/client.py.template rename to templates/modalities/{modality_slug}/providers/{provider_slug}/client.py.template diff --git a/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/providers/{provider_slug}/models.py.template b/templates/modalities/{modality_slug}/providers/{provider_slug}/models.py.template similarity index 100% rename from templates/modalities/{modality_slug}/src/celeste_{modality_slug}/providers/{provider_slug}/models.py.template rename to templates/modalities/{modality_slug}/providers/{provider_slug}/models.py.template diff --git a/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/providers/{provider_slug}/parameters.py.template b/templates/modalities/{modality_slug}/providers/{provider_slug}/parameters.py.template similarity index 100% rename from templates/modalities/{modality_slug}/src/celeste_{modality_slug}/providers/{provider_slug}/parameters.py.template rename to templates/modalities/{modality_slug}/providers/{provider_slug}/parameters.py.template diff --git a/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/streaming.py.template b/templates/modalities/{modality_slug}/streaming.py.template similarity index 100% rename from templates/modalities/{modality_slug}/src/celeste_{modality_slug}/streaming.py.template rename to templates/modalities/{modality_slug}/streaming.py.template diff --git a/templates/protocols/{protocol_slug}/__init__.py.template b/templates/protocols/{protocol_slug}/__init__.py.template new file mode 100644 index 00000000..f0d39103 --- /dev/null +++ b/templates/protocols/{protocol_slug}/__init__.py.template @@ -0,0 +1,6 @@ +"""{Protocol} protocol implementation.""" + +from .client import {Protocol}Client +from .streaming import {Protocol}Stream + +__all__ = ["{Protocol}Client", "{Protocol}Stream"] diff --git a/templates/protocols/{protocol_slug}/client.py.template b/templates/protocols/{protocol_slug}/client.py.template new file mode 100644 index 00000000..d4c1cb8d --- /dev/null +++ b/templates/protocols/{protocol_slug}/client.py.template @@ -0,0 +1,160 @@ +"""{Protocol} protocol client.""" + +from collections.abc import AsyncIterator +from typing import Any, ClassVar + +from celeste.client import APIMixin +from celeste.core import UsageField +from celeste.io import FinishReason +from celeste.mime_types import ApplicationMimeType + +from . import config + + +class {Protocol}Client(APIMixin): + """{Protocol} protocol client. + + Provides shared implementation for all providers using the {Protocol} API: + - _build_url() - Build URL with provider base URL (override for Vertex AI) + - _build_request() - Add model ID and streaming flag + - _make_request() - HTTP POST to protocol endpoint + - _make_stream_request() - HTTP streaming to protocol endpoint + - map_usage_fields() - Map usage fields to unified names + - _parse_usage() - Extract usage dict from response + - _parse_content() - Extract content from response + - _parse_finish_reason() - Extract finish reason from response + - _build_metadata() - Filter content fields + + Providers override ClassVars and hook methods: + - _default_base_url: ClassVar[str] - Provider's API base URL + - _default_endpoint: ClassVar[str] - Default endpoint path (override for non-standard paths) + - _build_url() - Override for Vertex AI URL routing + - map_usage_fields() - Override to add provider-specific usage fields + - _build_request() - Override to add provider-specific request fields + + Usage: + class {Provider}{Api}Client({Protocol}Client): + _default_base_url: ClassVar[str] = config.BASE_URL + """ + + _default_base_url: ClassVar[str] = config.DEFAULT_BASE_URL + _default_endpoint: ClassVar[str] = "" # Set to config.{Protocol}Endpoint.CREATE_... + + def _build_url(self, endpoint: str, streaming: bool = False) -> str: + """Build full URL for request. + + Override for Vertex AI support: + def _build_url(self, endpoint: str, streaming: bool = False) -> str: + if isinstance(self.auth, GoogleADC): + return self.auth.build_url(self._get_vertex_endpoint(endpoint, streaming)) + return super()._build_url(endpoint, streaming) + """ + return f"{self._default_base_url}{endpoint}" + + def _build_request( + self, + inputs: Any, + extra_body: dict[str, Any] | None = None, + streaming: bool = False, + **parameters: Any, + ) -> dict[str, Any]: + """Build request with model ID and streaming flag.""" + request_body = super()._build_request( + inputs, extra_body=extra_body, streaming=streaming, **parameters + ) + request_body["model"] = self.model.id + if streaming: + request_body["stream"] = True + return request_body + + async def _make_request( + self, + request_body: dict[str, Any], + *, + endpoint: str | None = None, + **parameters: Any, + ) -> dict[str, Any]: + """Make HTTP request to {Protocol} API endpoint.""" + if endpoint is None: + endpoint = self._default_endpoint + + headers = { + **self.auth.get_headers(), + "Content-Type": ApplicationMimeType.JSON, + } + + response = await self.http_client.post( + self._build_url(endpoint), + headers=headers, + json_body=request_body, + ) + self._handle_error_response(response) + data: dict[str, Any] = response.json() + return data + + def _make_stream_request( + self, + request_body: dict[str, Any], + *, + endpoint: str | None = None, + **parameters: Any, + ) -> AsyncIterator[dict[str, Any]]: + """Make streaming request to {Protocol} API endpoint.""" + if endpoint is None: + endpoint = self._default_endpoint + + headers = { + **self.auth.get_headers(), + "Content-Type": ApplicationMimeType.JSON, + } + + return self.http_client.stream_post( + self._build_url(endpoint, streaming=True), + headers=headers, + json_body=request_body, + ) + + @staticmethod + def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | float | None]: + """Map {Protocol} usage fields to unified names. + + Override in provider client to add fields (e.g., cached_tokens, reasoning_tokens). + """ + return { + UsageField.INPUT_TOKENS: usage_data.get("..."), + UsageField.OUTPUT_TOKENS: usage_data.get("..."), + UsageField.TOTAL_TOKENS: usage_data.get("..."), + } + + def _parse_usage( + self, response_data: dict[str, Any] + ) -> dict[str, int | float | None]: + """Extract usage data from {Protocol} API response.""" + usage_data = response_data.get("usage", {}) + return {Protocol}Client.map_usage_fields(usage_data) + + def _parse_content(self, response_data: dict[str, Any]) -> Any: + """Parse content from {Protocol} API response. + + Returns raw content structure that modality clients extract from. + """ + content = response_data.get("...", []) + if not content: + msg = "No content in response" + raise ValueError(msg) + return content + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> FinishReason: + """Extract finish reason from {Protocol} API response.""" + return FinishReason(reason=None) + + def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: + """Build metadata dictionary, filtering out content fields.""" + content_fields = {"..."} + filtered_data = { + k: v for k, v in response_data.items() if k not in content_fields + } + return super()._build_metadata(filtered_data) + + +__all__ = ["{Protocol}Client"] diff --git a/templates/protocols/{protocol_slug}/config.py.template b/templates/protocols/{protocol_slug}/config.py.template new file mode 100644 index 00000000..942ed67c --- /dev/null +++ b/templates/protocols/{protocol_slug}/config.py.template @@ -0,0 +1,17 @@ +"""{Protocol} protocol endpoint configuration.""" + +from enum import StrEnum + + +class {Protocol}Endpoint(StrEnum): + """Endpoints for the {Protocol} protocol.""" + + # Endpoint names use action verbs: CREATE, GET, LIST, DELETE, STREAM + # Examples: + # CREATE_CHAT = "/v1/chat/completions" + # CREATE_RESPONSE = "/v1/responses" + # LIST_MODELS = "/v1/models" + pass + + +DEFAULT_BASE_URL = "http://localhost:8000" diff --git a/templates/protocols/{protocol_slug}/streaming.py.template b/templates/protocols/{protocol_slug}/streaming.py.template new file mode 100644 index 00000000..b36414f2 --- /dev/null +++ b/templates/protocols/{protocol_slug}/streaming.py.template @@ -0,0 +1,72 @@ +"""{Protocol} protocol SSE parsing for streaming.""" + +from typing import Any + +from celeste.io import FinishReason + +from .client import {Protocol}Client + + +class {Protocol}Stream: + """{Protocol} protocol SSE parsing mixin. + + Provides shared implementation for streaming parsing (protocol level): + - _parse_chunk_content(event_data) - Extract content from SSE event + - _parse_chunk_usage(event_data) - Extract and normalize usage from SSE event + - _parse_chunk_finish_reason(event_data) - Extract finish reason from SSE event + - _build_stream_metadata(raw_events) - Filter content-only events + + Provider streams inherit this and override methods for provider-specific behavior + (e.g., thinking model content parsing, non-standard usage locations). + + Modality streams call super() methods which resolve to this via MRO. + """ + + def _parse_chunk_content(self, event_data: dict[str, Any]) -> str | None: + """Extract content from SSE event. + + Returns content string if present, None otherwise. + Override for provider-specific content formats (e.g., list-based thinking model content). + """ + # TODO: Implement protocol-specific content extraction + return None + + def _parse_chunk_usage( + self, event_data: dict[str, Any] + ) -> dict[str, int | float | None] | None: + """Extract and normalize usage from SSE event. + + If usage data is present, map it with: + {Protocol}Client.map_usage_fields(usage_data) + + Override for non-standard usage locations (e.g., usage in choices[0]). + Returns normalized usage dict if present, None otherwise. + """ + usage_data = event_data.get("usage") + if isinstance(usage_data, dict): + return {Protocol}Client.map_usage_fields(usage_data) + + return None + + def _parse_chunk_finish_reason( + self, event_data: dict[str, Any] + ) -> FinishReason | None: + """Extract finish reason from SSE event. + + Override for provider-specific finish reason extraction. + Returns FinishReason if present, None otherwise. + """ + return None + + def _build_stream_metadata( + self, raw_events: list[dict[str, Any]] + ) -> dict[str, Any]: + """Filter content-only events for size efficiency. + + Override to customize which events are kept in metadata. + """ + filtered = [event for event in raw_events if event.get("usage")] + return super()._build_stream_metadata(filtered) # type: ignore[misc, no-any-return] + + +__all__ = ["{Protocol}Stream"] diff --git a/templates/providers/{provider_slug}/src/celeste_{provider_slug}/__init__.py.template b/templates/providers/{provider_slug}/__init__.py.template similarity index 100% rename from templates/providers/{provider_slug}/src/celeste_{provider_slug}/__init__.py.template rename to templates/providers/{provider_slug}/__init__.py.template diff --git a/templates/providers/{provider_slug}/src/celeste_{provider_slug}/py.typed.template b/templates/providers/{provider_slug}/py.typed.template similarity index 100% rename from templates/providers/{provider_slug}/src/celeste_{provider_slug}/py.typed.template rename to templates/providers/{provider_slug}/py.typed.template diff --git a/templates/providers/{provider_slug}/src/celeste_{provider_slug}/{api_slug}/__init__.py.template b/templates/providers/{provider_slug}/{api_slug}/__init__.py.template similarity index 100% rename from templates/providers/{provider_slug}/src/celeste_{provider_slug}/{api_slug}/__init__.py.template rename to templates/providers/{provider_slug}/{api_slug}/__init__.py.template diff --git a/templates/providers/{provider_slug}/src/celeste_{provider_slug}/{api_slug}/client.py.template b/templates/providers/{provider_slug}/{api_slug}/client.py.template similarity index 100% rename from templates/providers/{provider_slug}/src/celeste_{provider_slug}/{api_slug}/client.py.template rename to templates/providers/{provider_slug}/{api_slug}/client.py.template diff --git a/templates/providers/{provider_slug}/src/celeste_{provider_slug}/{api_slug}/config.py.template b/templates/providers/{provider_slug}/{api_slug}/config.py.template similarity index 100% rename from templates/providers/{provider_slug}/src/celeste_{provider_slug}/{api_slug}/config.py.template rename to templates/providers/{provider_slug}/{api_slug}/config.py.template diff --git a/templates/providers/{provider_slug}/src/celeste_{provider_slug}/{api_slug}/parameters.py.template b/templates/providers/{provider_slug}/{api_slug}/parameters.py.template similarity index 100% rename from templates/providers/{provider_slug}/src/celeste_{provider_slug}/{api_slug}/parameters.py.template rename to templates/providers/{provider_slug}/{api_slug}/parameters.py.template diff --git a/templates/providers/{provider_slug}/src/celeste_{provider_slug}/{api_slug}/streaming.py.template b/templates/providers/{provider_slug}/{api_slug}/streaming.py.template similarity index 100% rename from templates/providers/{provider_slug}/src/celeste_{provider_slug}/{api_slug}/streaming.py.template rename to templates/providers/{provider_slug}/{api_slug}/streaming.py.template diff --git a/tests/unit_tests/test_provider_api_templates.py b/tests/unit_tests/test_provider_api_templates.py index 8051b2d1..a7380abc 100644 --- a/tests/unit_tests/test_provider_api_templates.py +++ b/tests/unit_tests/test_provider_api_templates.py @@ -35,8 +35,6 @@ def _template_client_path() -> Path: / "templates" / "providers" / "{provider_slug}" - / "src" - / "celeste_{provider_slug}" / "{api_slug}" / "client.py.template" ) @@ -115,7 +113,7 @@ def _provider_api_client_files() -> list[Path]: def _inherits_from_protocol(tree: ast.Module) -> bool: """Check if the first class inherits from a known protocol client.""" - protocol_bases = {"OpenResponsesClient"} + protocol_bases = {"OpenResponsesClient", "ChatCompletionsClient"} for node in tree.body: if isinstance(node, ast.ClassDef): for base in node.bases: From 575b6b80ca472643de8c8400d17f1f7ecf5b9da0 Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Fri, 20 Feb 2026 17:49:57 +0100 Subject: [PATCH 2/5] fix: align both protocols with template and add parameters.py - Add missing parameters.py.template to protocol templates - Fix OpenResponses client to match template: add _default_endpoint ClassVar, _build_url() hook, remove unused base_url kwargs - Fix ChatCompletions client: rename _default_chat_endpoint to _default_endpoint per template convention - Fix streaming docstrings in both protocols to match template - Create ChatCompletions protocol parameters.py with shared TemperatureMapper and MaxTokensMapper - Remove duplicated mappers from 4 provider parameter files - Rewire modality text parameter imports to protocol path --- .../text/providers/deepseek/parameters.py | 8 +-- .../text/providers/groq/parameters.py | 8 +-- .../text/providers/mistral/parameters.py | 8 +-- .../text/providers/moonshot/parameters.py | 8 +-- .../protocols/chatcompletions/client.py | 14 +++-- .../protocols/chatcompletions/parameters.py | 45 ++++++++++++++++ .../protocols/chatcompletions/streaming.py | 3 ++ src/celeste/protocols/openresponses/client.py | 40 ++++++++++---- .../protocols/openresponses/streaming.py | 6 ++- .../providers/deepseek/chat/parameters.py | 38 +------------ src/celeste/providers/groq/chat/client.py | 6 +-- src/celeste/providers/groq/chat/parameters.py | 42 +-------------- .../providers/mistral/chat/parameters.py | 43 +-------------- .../providers/moonshot/chat/parameters.py | 38 +------------ .../{protocol_slug}/parameters.py.template | 53 +++++++++++++++++++ 15 files changed, 166 insertions(+), 194 deletions(-) create mode 100644 src/celeste/protocols/chatcompletions/parameters.py create mode 100644 templates/protocols/{protocol_slug}/parameters.py.template diff --git a/src/celeste/modalities/text/providers/deepseek/parameters.py b/src/celeste/modalities/text/providers/deepseek/parameters.py index 02b9462b..9ba93167 100644 --- a/src/celeste/modalities/text/providers/deepseek/parameters.py +++ b/src/celeste/modalities/text/providers/deepseek/parameters.py @@ -1,14 +1,14 @@ """DeepSeek parameter mappers for text.""" from celeste.parameters import ParameterMapper -from celeste.providers.deepseek.chat.parameters import ( +from celeste.protocols.chatcompletions.parameters import ( MaxTokensMapper as _MaxTokensMapper, ) -from celeste.providers.deepseek.chat.parameters import ( - ResponseFormatMapper as _ResponseFormatMapper, +from celeste.protocols.chatcompletions.parameters import ( + TemperatureMapper as _TemperatureMapper, ) from celeste.providers.deepseek.chat.parameters import ( - TemperatureMapper as _TemperatureMapper, + ResponseFormatMapper as _ResponseFormatMapper, ) from ...parameters import TextParameter diff --git a/src/celeste/modalities/text/providers/groq/parameters.py b/src/celeste/modalities/text/providers/groq/parameters.py index cf38ba2c..0a55f443 100644 --- a/src/celeste/modalities/text/providers/groq/parameters.py +++ b/src/celeste/modalities/text/providers/groq/parameters.py @@ -1,14 +1,14 @@ """Groq parameter mappers for text.""" from celeste.parameters import ParameterMapper -from celeste.providers.groq.chat.parameters import ( +from celeste.protocols.chatcompletions.parameters import ( MaxTokensMapper as _MaxTokensMapper, ) -from celeste.providers.groq.chat.parameters import ( - ResponseFormatMapper as _ResponseFormatMapper, +from celeste.protocols.chatcompletions.parameters import ( + TemperatureMapper as _TemperatureMapper, ) from celeste.providers.groq.chat.parameters import ( - TemperatureMapper as _TemperatureMapper, + ResponseFormatMapper as _ResponseFormatMapper, ) from ...parameters import TextParameter diff --git a/src/celeste/modalities/text/providers/mistral/parameters.py b/src/celeste/modalities/text/providers/mistral/parameters.py index 9c644b0f..1f545d5a 100644 --- a/src/celeste/modalities/text/providers/mistral/parameters.py +++ b/src/celeste/modalities/text/providers/mistral/parameters.py @@ -4,14 +4,14 @@ from celeste.models import Model from celeste.parameters import ParameterMapper -from celeste.providers.mistral.chat.parameters import ( +from celeste.protocols.chatcompletions.parameters import ( MaxTokensMapper as _MaxTokensMapper, ) -from celeste.providers.mistral.chat.parameters import ( - ResponseFormatMapper as _ResponseFormatMapper, +from celeste.protocols.chatcompletions.parameters import ( + TemperatureMapper as _TemperatureMapper, ) from celeste.providers.mistral.chat.parameters import ( - TemperatureMapper as _TemperatureMapper, + ResponseFormatMapper as _ResponseFormatMapper, ) from ...parameters import TextParameter diff --git a/src/celeste/modalities/text/providers/moonshot/parameters.py b/src/celeste/modalities/text/providers/moonshot/parameters.py index 1889cff2..73d53cd7 100644 --- a/src/celeste/modalities/text/providers/moonshot/parameters.py +++ b/src/celeste/modalities/text/providers/moonshot/parameters.py @@ -1,14 +1,14 @@ """Moonshot parameter mappers for text.""" from celeste.parameters import ParameterMapper -from celeste.providers.moonshot.chat.parameters import ( +from celeste.protocols.chatcompletions.parameters import ( MaxTokensMapper as _MaxTokensMapper, ) -from celeste.providers.moonshot.chat.parameters import ( - ResponseFormatMapper as _ResponseFormatMapper, +from celeste.protocols.chatcompletions.parameters import ( + TemperatureMapper as _TemperatureMapper, ) from celeste.providers.moonshot.chat.parameters import ( - TemperatureMapper as _TemperatureMapper, + ResponseFormatMapper as _ResponseFormatMapper, ) from ...parameters import TextParameter diff --git a/src/celeste/protocols/chatcompletions/client.py b/src/celeste/protocols/chatcompletions/client.py index 63ae0809..d3d12883 100644 --- a/src/celeste/protocols/chatcompletions/client.py +++ b/src/celeste/protocols/chatcompletions/client.py @@ -26,15 +26,19 @@ class ChatCompletionsClient(APIMixin): - _build_metadata() - Filter content fields Providers override ClassVars and hook methods: - - _default_base_url - Provider's API base URL - - _default_chat_endpoint - Default endpoint path (override for non-standard paths) + - _default_base_url: ClassVar[str] - Provider's API base URL + - _default_endpoint: ClassVar[str] - Default endpoint path (override for non-standard paths) - _build_url() - Override for Vertex AI URL routing - map_usage_fields() - Override to add provider-specific usage fields - _build_request() - Override to add provider-specific request fields + + Usage: + class DeepSeekChatClient(ChatCompletionsClient): + _default_base_url: ClassVar[str] = config.BASE_URL """ _default_base_url: ClassVar[str] = config.DEFAULT_BASE_URL - _default_chat_endpoint: ClassVar[str] = config.ChatCompletionsEndpoint.CREATE_CHAT + _default_endpoint: ClassVar[str] = config.ChatCompletionsEndpoint.CREATE_CHAT def _build_url(self, endpoint: str, streaming: bool = False) -> str: """Build full URL for request. @@ -68,7 +72,7 @@ async def _make_request( ) -> dict[str, Any]: """Make HTTP request to Chat Completions API endpoint.""" if endpoint is None: - endpoint = self._default_chat_endpoint + endpoint = self._default_endpoint headers = { **self.auth.get_headers(), @@ -93,7 +97,7 @@ def _make_stream_request( ) -> AsyncIterator[dict[str, Any]]: """Make streaming request to Chat Completions API endpoint.""" if endpoint is None: - endpoint = self._default_chat_endpoint + endpoint = self._default_endpoint headers = { **self.auth.get_headers(), diff --git a/src/celeste/protocols/chatcompletions/parameters.py b/src/celeste/protocols/chatcompletions/parameters.py new file mode 100644 index 00000000..d58385a6 --- /dev/null +++ b/src/celeste/protocols/chatcompletions/parameters.py @@ -0,0 +1,45 @@ +"""Chat Completions protocol parameter mappers.""" + +from typing import Any + +from celeste.models import Model +from celeste.parameters import ParameterMapper + + +class TemperatureMapper(ParameterMapper): + """Map temperature to Chat Completions temperature field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform temperature into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["temperature"] = validated_value + return request + + +class MaxTokensMapper(ParameterMapper): + """Map max_tokens to Chat Completions max_tokens field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform max_tokens into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["max_tokens"] = validated_value + return request + + +__all__ = ["MaxTokensMapper", "TemperatureMapper"] diff --git a/src/celeste/protocols/chatcompletions/streaming.py b/src/celeste/protocols/chatcompletions/streaming.py index 1de35539..c68bb69b 100644 --- a/src/celeste/protocols/chatcompletions/streaming.py +++ b/src/celeste/protocols/chatcompletions/streaming.py @@ -16,6 +16,9 @@ class ChatCompletionsStream: - _parse_chunk_finish_reason(event_data) - Extract finish reason from SSE event - _build_stream_metadata(raw_events) - Filter content-only events + Provider streams inherit this and override methods for provider-specific behavior + (e.g., thinking model content parsing, non-standard usage locations). + Modality streams call super() methods which resolve to this via MRO. """ diff --git a/src/celeste/protocols/openresponses/client.py b/src/celeste/protocols/openresponses/client.py index e6f1d989..562357fb 100644 --- a/src/celeste/protocols/openresponses/client.py +++ b/src/celeste/protocols/openresponses/client.py @@ -15,17 +15,41 @@ class OpenResponsesClient(APIMixin): """OpenResponses protocol client. Provides shared implementation for all providers using the Responses API: + - _build_url() - Build URL with provider base URL (override for Vertex AI) + - _build_request() - Add model ID and streaming flag - _make_request() - HTTP POST to /v1/responses - _make_stream_request() - HTTP streaming to /v1/responses + - map_usage_fields() - Map usage fields to unified names - _parse_usage() - Extract usage dict from response - _parse_content() - Extract output array from response - _parse_finish_reason() - Extract finish reason from response - _build_metadata() - Filter content fields - Providers override _default_base_url to set their API base URL. + Providers override ClassVars and hook methods: + - _default_base_url: ClassVar[str] - Provider's API base URL + - _default_endpoint: ClassVar[str] - Default endpoint path (override for non-standard paths) + - _build_url() - Override for Vertex AI URL routing + - map_usage_fields() - Override to add provider-specific usage fields + - _build_request() - Override to add provider-specific request fields + + Usage: + class OpenAIResponsesClient(OpenResponsesClient): + _default_base_url: ClassVar[str] = config.BASE_URL """ _default_base_url: ClassVar[str] = config.DEFAULT_BASE_URL + _default_endpoint: ClassVar[str] = config.OpenResponsesEndpoint.CREATE_RESPONSE + + def _build_url(self, endpoint: str, streaming: bool = False) -> str: + """Build full URL for request. + + Override for Vertex AI support: + def _build_url(self, endpoint: str, streaming: bool = False) -> str: + if isinstance(self.auth, GoogleADC): + return self.auth.build_url(self._get_vertex_endpoint(endpoint, streaming)) + return super()._build_url(endpoint, streaming) + """ + return f"{self._default_base_url}{endpoint}" def _build_request( self, @@ -48,14 +72,11 @@ async def _make_request( request_body: dict[str, Any], *, endpoint: str | None = None, - base_url: str | None = None, **parameters: Any, ) -> dict[str, Any]: """Make HTTP request to Responses API endpoint.""" if endpoint is None: - endpoint = config.OpenResponsesEndpoint.CREATE_RESPONSE - if base_url is None: - base_url = self._default_base_url + endpoint = self._default_endpoint headers = { **self.auth.get_headers(), @@ -63,7 +84,7 @@ async def _make_request( } response = await self.http_client.post( - f"{base_url}{endpoint}", + self._build_url(endpoint), headers=headers, json_body=request_body, ) @@ -76,14 +97,11 @@ def _make_stream_request( request_body: dict[str, Any], *, endpoint: str | None = None, - base_url: str | None = None, **parameters: Any, ) -> AsyncIterator[dict[str, Any]]: """Make streaming request to Responses API endpoint.""" if endpoint is None: - endpoint = config.OpenResponsesEndpoint.CREATE_RESPONSE - if base_url is None: - base_url = self._default_base_url + endpoint = self._default_endpoint headers = { **self.auth.get_headers(), @@ -91,7 +109,7 @@ def _make_stream_request( } return self.http_client.stream_post( - f"{base_url}{endpoint}", + self._build_url(endpoint, streaming=True), headers=headers, json_body=request_body, ) diff --git a/src/celeste/protocols/openresponses/streaming.py b/src/celeste/protocols/openresponses/streaming.py index 2b070904..d838f58b 100644 --- a/src/celeste/protocols/openresponses/streaming.py +++ b/src/celeste/protocols/openresponses/streaming.py @@ -10,10 +10,14 @@ class OpenResponsesStream: """OpenResponses protocol SSE parsing mixin. - Provides shared implementation for streaming parsing (provider API level): + Provides shared implementation for streaming parsing (protocol level): - _parse_chunk_content(event_data) - Extract content from SSE event - _parse_chunk_usage(event_data) - Extract and normalize usage from SSE event - _parse_chunk_finish_reason(event_data) - Extract finish reason from SSE event + - _build_stream_metadata(raw_events) - Filter content-only events + + Provider streams inherit this and override methods for provider-specific behavior + (e.g., thinking model content parsing, non-standard usage locations). Modality streams call super() methods which resolve to this via MRO. """ diff --git a/src/celeste/providers/deepseek/chat/parameters.py b/src/celeste/providers/deepseek/chat/parameters.py index 7d6f084d..92c17650 100644 --- a/src/celeste/providers/deepseek/chat/parameters.py +++ b/src/celeste/providers/deepseek/chat/parameters.py @@ -10,42 +10,6 @@ from celeste.types import TextContent -class TemperatureMapper(ParameterMapper): - """Map temperature to DeepSeek temperature field.""" - - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Transform temperature into provider request.""" - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - request["temperature"] = validated_value - return request - - -class MaxTokensMapper(ParameterMapper): - """Map max_tokens to DeepSeek max_tokens field.""" - - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Transform max_tokens into provider request.""" - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - request["max_tokens"] = validated_value - return request - - class ResponseFormatMapper(ParameterMapper): """Map output_schema to DeepSeek response_format field. @@ -94,4 +58,4 @@ def parse_output(self, content: TextContent, value: object | None) -> TextConten return TypeAdapter(value).validate_python(parsed) -__all__ = ["MaxTokensMapper", "ResponseFormatMapper", "TemperatureMapper"] +__all__ = ["ResponseFormatMapper"] diff --git a/src/celeste/providers/groq/chat/client.py b/src/celeste/providers/groq/chat/client.py index a66fad8e..b4ab578e 100644 --- a/src/celeste/providers/groq/chat/client.py +++ b/src/celeste/providers/groq/chat/client.py @@ -12,13 +12,11 @@ class GroqChatClient(ChatCompletionsClient): Inherits shared Chat Completions implementation. Only overrides: - _default_base_url - Groq API base URL - - _default_chat_endpoint - Groq uses /openai/v1/chat/completions + - _default_endpoint - Groq uses /openai/v1/chat/completions """ _default_base_url: ClassVar[str] = config.BASE_URL - _default_chat_endpoint: ClassVar[str] = ( - config.GroqChatEndpoint.CREATE_CHAT_COMPLETION - ) + _default_endpoint: ClassVar[str] = config.GroqChatEndpoint.CREATE_CHAT_COMPLETION __all__ = ["GroqChatClient"] diff --git a/src/celeste/providers/groq/chat/parameters.py b/src/celeste/providers/groq/chat/parameters.py index 26ad7170..00be580c 100644 --- a/src/celeste/providers/groq/chat/parameters.py +++ b/src/celeste/providers/groq/chat/parameters.py @@ -11,42 +11,6 @@ from celeste.types import TextContent -class TemperatureMapper(ParameterMapper): - """Map temperature to Groq temperature field.""" - - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Transform temperature into provider request.""" - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - request["temperature"] = validated_value - return request - - -class MaxTokensMapper(ParameterMapper): - """Map max_tokens to Groq max_tokens field.""" - - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Transform max_tokens into provider request.""" - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - request["max_tokens"] = validated_value - return request - - class ResponseFormatMapper(ParameterMapper): """Map output_schema to Groq response_format field (json_schema mode). @@ -120,8 +84,4 @@ def parse_output(self, content: TextContent, value: object | None) -> TextConten return TypeAdapter(value).validate_python(parsed_json) -__all__ = [ - "MaxTokensMapper", - "ResponseFormatMapper", - "TemperatureMapper", -] +__all__ = ["ResponseFormatMapper"] diff --git a/src/celeste/providers/mistral/chat/parameters.py b/src/celeste/providers/mistral/chat/parameters.py index 8a5e1a84..c44f636a 100644 --- a/src/celeste/providers/mistral/chat/parameters.py +++ b/src/celeste/providers/mistral/chat/parameters.py @@ -11,42 +11,6 @@ from celeste.types import TextContent -class TemperatureMapper(ParameterMapper): - """Map temperature to Mistral temperature field.""" - - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Transform temperature into provider request.""" - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - request["temperature"] = validated_value - return request - - -class MaxTokensMapper(ParameterMapper): - """Map max_tokens to Mistral max_tokens field.""" - - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Transform max_tokens into provider request.""" - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - request["max_tokens"] = validated_value - return request - - class WebSearchMapper(ParameterMapper): """Map web_search to Mistral tools field.""" @@ -127,9 +91,4 @@ def parse_output(self, content: TextContent, value: object | None) -> TextConten return TypeAdapter(value).validate_python(parsed) -__all__ = [ - "MaxTokensMapper", - "ResponseFormatMapper", - "TemperatureMapper", - "WebSearchMapper", -] +__all__ = ["ResponseFormatMapper", "WebSearchMapper"] diff --git a/src/celeste/providers/moonshot/chat/parameters.py b/src/celeste/providers/moonshot/chat/parameters.py index d0ab3eb8..6a87def9 100644 --- a/src/celeste/providers/moonshot/chat/parameters.py +++ b/src/celeste/providers/moonshot/chat/parameters.py @@ -10,42 +10,6 @@ from celeste.types import TextContent -class TemperatureMapper(ParameterMapper): - """Map temperature to Moonshot temperature field.""" - - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Transform temperature into provider request.""" - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - request["temperature"] = validated_value - return request - - -class MaxTokensMapper(ParameterMapper): - """Map max_tokens to Moonshot max_tokens field.""" - - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Transform max_tokens into provider request.""" - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - request["max_tokens"] = validated_value - return request - - class ResponseFormatMapper(ParameterMapper): """Map output_schema to Moonshot response_format field. @@ -94,4 +58,4 @@ def parse_output(self, content: TextContent, value: object | None) -> TextConten return TypeAdapter(value).validate_python(parsed) -__all__ = ["MaxTokensMapper", "ResponseFormatMapper", "TemperatureMapper"] +__all__ = ["ResponseFormatMapper"] diff --git a/templates/protocols/{protocol_slug}/parameters.py.template b/templates/protocols/{protocol_slug}/parameters.py.template new file mode 100644 index 00000000..74cbbdda --- /dev/null +++ b/templates/protocols/{protocol_slug}/parameters.py.template @@ -0,0 +1,53 @@ +"""{Protocol} protocol parameter mappers. + +Naming convention: +- Mapper class name MUST match the protocol's API parameter name +- Example: API param "temperature" -> class TemperatureMapper +- These are base classes WITHOUT ``name`` -- modality mappers inherit and add ``name`` +""" + +from typing import Any + +from celeste.models import Model +from celeste.parameters import ParameterMapper + + +# PATTERN 1: Simple flat mapping +# class TemperatureMapper(ParameterMapper): +# """Map temperature to {Protocol} temperature field.""" +# +# def map( +# self, +# request: dict[str, Any], +# value: object, +# model: Model, +# ) -> dict[str, Any]: +# """Transform temperature into provider request.""" +# validated_value = self._validate_value(value, model) +# if validated_value is None: +# return request +# +# request["temperature"] = validated_value +# return request + + +# PATTERN 2: Nested mapping +# class ReasoningEffortMapper(ParameterMapper): +# """Map reasoning_effort to {Protocol} reasoning.effort field.""" +# +# def map( +# self, +# request: dict[str, Any], +# value: object, +# model: Model, +# ) -> dict[str, Any]: +# """Transform reasoning_effort into provider request.""" +# validated_value = self._validate_value(value, model) +# if validated_value is None: +# return request +# +# request.setdefault("reasoning", {})["effort"] = validated_value +# return request + + +__all__: list[str] = [] From 607305860880fd40f28988a54575db806d60cc70 Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Fri, 20 Feb 2026 18:06:29 +0100 Subject: [PATCH 3/5] fix: align docstrings across protocol templates and implementations --- src/celeste/protocols/chatcompletions/client.py | 11 ++++++----- .../protocols/{protocol_slug}/client.py.template | 5 +---- .../protocols/{protocol_slug}/parameters.py.template | 8 +------- 3 files changed, 8 insertions(+), 16 deletions(-) diff --git a/src/celeste/protocols/chatcompletions/client.py b/src/celeste/protocols/chatcompletions/client.py index d3d12883..5fd008ee 100644 --- a/src/celeste/protocols/chatcompletions/client.py +++ b/src/celeste/protocols/chatcompletions/client.py @@ -43,7 +43,11 @@ class DeepSeekChatClient(ChatCompletionsClient): def _build_url(self, endpoint: str, streaming: bool = False) -> str: """Build full URL for request. - Override for Vertex AI support. + Override for Vertex AI support: + def _build_url(self, endpoint: str, streaming: bool = False) -> str: + if isinstance(self.auth, GoogleADC): + return self.auth.build_url(self._get_vertex_endpoint(endpoint, streaming)) + return super()._build_url(endpoint, streaming) """ return f"{self._default_base_url}{endpoint}" @@ -130,10 +134,7 @@ def _parse_usage( return ChatCompletionsClient.map_usage_fields(usage_data) def _parse_content(self, response_data: dict[str, Any]) -> Any: - """Parse choices array from Chat Completions API response. - - Returns raw choices array that modality clients extract from. - """ + """Parse choices array from Chat Completions API response.""" choices = response_data.get("choices", []) if not choices: msg = "No choices in response" diff --git a/templates/protocols/{protocol_slug}/client.py.template b/templates/protocols/{protocol_slug}/client.py.template index d4c1cb8d..8c187dac 100644 --- a/templates/protocols/{protocol_slug}/client.py.template +++ b/templates/protocols/{protocol_slug}/client.py.template @@ -134,10 +134,7 @@ class {Protocol}Client(APIMixin): return {Protocol}Client.map_usage_fields(usage_data) def _parse_content(self, response_data: dict[str, Any]) -> Any: - """Parse content from {Protocol} API response. - - Returns raw content structure that modality clients extract from. - """ + """Parse content from {Protocol} API response.""" content = response_data.get("...", []) if not content: msg = "No content in response" diff --git a/templates/protocols/{protocol_slug}/parameters.py.template b/templates/protocols/{protocol_slug}/parameters.py.template index 74cbbdda..7ada0a16 100644 --- a/templates/protocols/{protocol_slug}/parameters.py.template +++ b/templates/protocols/{protocol_slug}/parameters.py.template @@ -1,10 +1,4 @@ -"""{Protocol} protocol parameter mappers. - -Naming convention: -- Mapper class name MUST match the protocol's API parameter name -- Example: API param "temperature" -> class TemperatureMapper -- These are base classes WITHOUT ``name`` -- modality mappers inherit and add ``name`` -""" +"""{Protocol} protocol parameter mappers.""" from typing import Any From c43af5d9259f94e9d31f02a5c2e399cb468ec3d5 Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Fri, 20 Feb 2026 20:10:47 +0100 Subject: [PATCH 4/5] refactor: move ResponseFormatMapper to protocol + fix _parse_usage dispatch - Add ResponseFormatMapper to chatcompletions protocol with json_object default and shared parse_output() logic - Delete DeepSeek/Moonshot provider parameters (now use protocol default) - Simplify Groq/Mistral to inherit parse_output(), override only map() - Fix _parse_usage to use self.map_usage_fields() for proper MRO dispatch in both chatcompletions and openresponses protocol clients - Delete DeepSeek _parse_usage override (no longer needed) - Fix Moonshot streaming misleading MoonshotChatClient.map_usage_fields reference to ChatCompletionsClient.map_usage_fields - Update protocol templates to match --- .../text/providers/deepseek/parameters.py | 6 +- .../text/providers/moonshot/parameters.py | 6 +- .../protocols/chatcompletions/client.py | 2 +- .../protocols/chatcompletions/parameters.py | 54 +++++++++++++++- src/celeste/protocols/openresponses/client.py | 2 +- src/celeste/providers/deepseek/chat/client.py | 7 --- .../providers/deepseek/chat/parameters.py | 61 ------------------- src/celeste/providers/groq/chat/parameters.py | 32 ++-------- .../providers/mistral/chat/parameters.py | 26 ++------ .../providers/moonshot/chat/parameters.py | 61 ------------------- .../providers/moonshot/chat/streaming.py | 9 +-- .../{protocol_slug}/client.py.template | 2 +- .../{protocol_slug}/parameters.py.template | 28 +++++++++ 13 files changed, 104 insertions(+), 192 deletions(-) delete mode 100644 src/celeste/providers/deepseek/chat/parameters.py delete mode 100644 src/celeste/providers/moonshot/chat/parameters.py diff --git a/src/celeste/modalities/text/providers/deepseek/parameters.py b/src/celeste/modalities/text/providers/deepseek/parameters.py index 9ba93167..4dd6bd27 100644 --- a/src/celeste/modalities/text/providers/deepseek/parameters.py +++ b/src/celeste/modalities/text/providers/deepseek/parameters.py @@ -5,11 +5,11 @@ MaxTokensMapper as _MaxTokensMapper, ) from celeste.protocols.chatcompletions.parameters import ( - TemperatureMapper as _TemperatureMapper, -) -from celeste.providers.deepseek.chat.parameters import ( ResponseFormatMapper as _ResponseFormatMapper, ) +from celeste.protocols.chatcompletions.parameters import ( + TemperatureMapper as _TemperatureMapper, +) from ...parameters import TextParameter diff --git a/src/celeste/modalities/text/providers/moonshot/parameters.py b/src/celeste/modalities/text/providers/moonshot/parameters.py index 73d53cd7..3a232519 100644 --- a/src/celeste/modalities/text/providers/moonshot/parameters.py +++ b/src/celeste/modalities/text/providers/moonshot/parameters.py @@ -5,11 +5,11 @@ MaxTokensMapper as _MaxTokensMapper, ) from celeste.protocols.chatcompletions.parameters import ( - TemperatureMapper as _TemperatureMapper, -) -from celeste.providers.moonshot.chat.parameters import ( ResponseFormatMapper as _ResponseFormatMapper, ) +from celeste.protocols.chatcompletions.parameters import ( + TemperatureMapper as _TemperatureMapper, +) from ...parameters import TextParameter diff --git a/src/celeste/protocols/chatcompletions/client.py b/src/celeste/protocols/chatcompletions/client.py index 5fd008ee..c75e803d 100644 --- a/src/celeste/protocols/chatcompletions/client.py +++ b/src/celeste/protocols/chatcompletions/client.py @@ -131,7 +131,7 @@ def _parse_usage( ) -> dict[str, int | float | None]: """Extract usage data from Chat Completions API response.""" usage_data = response_data.get("usage", {}) - return ChatCompletionsClient.map_usage_fields(usage_data) + return self.map_usage_fields(usage_data) def _parse_content(self, response_data: dict[str, Any]) -> Any: """Parse choices array from Chat Completions API response.""" diff --git a/src/celeste/protocols/chatcompletions/parameters.py b/src/celeste/protocols/chatcompletions/parameters.py index d58385a6..c8fc8d4b 100644 --- a/src/celeste/protocols/chatcompletions/parameters.py +++ b/src/celeste/protocols/chatcompletions/parameters.py @@ -1,9 +1,13 @@ """Chat Completions protocol parameter mappers.""" -from typing import Any +import json +from typing import Any, get_origin + +from pydantic import BaseModel, TypeAdapter from celeste.models import Model from celeste.parameters import ParameterMapper +from celeste.types import TextContent class TemperatureMapper(ParameterMapper): @@ -42,4 +46,50 @@ def map( return request -__all__ = ["MaxTokensMapper", "TemperatureMapper"] +class ResponseFormatMapper(ParameterMapper): + """Map output_schema to Chat Completions response_format field. + + Default uses json_object mode (no schema validation server-side). + Providers with json_schema support override map() only. + """ + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform output_schema into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["response_format"] = {"type": "json_object"} + return request + + def parse_output(self, content: TextContent, value: object | None) -> TextContent: + """Parse JSON string to typed output via Pydantic TypeAdapter.""" + if value is None: + return content + + if isinstance(content, BaseModel): + return content + if isinstance(content, list) and content and isinstance(content[0], BaseModel): + return content + + if isinstance(content, str): + parsed = json.loads(content, strict=False) + else: + parsed = content + + origin = get_origin(value) + if origin is list and isinstance(parsed, dict): + if "items" in parsed: + parsed = parsed["items"] + else: + parsed = list(parsed.values()) + + return TypeAdapter(value).validate_python(parsed) + + +__all__ = ["MaxTokensMapper", "ResponseFormatMapper", "TemperatureMapper"] diff --git a/src/celeste/protocols/openresponses/client.py b/src/celeste/protocols/openresponses/client.py index 562357fb..96e1854b 100644 --- a/src/celeste/protocols/openresponses/client.py +++ b/src/celeste/protocols/openresponses/client.py @@ -132,7 +132,7 @@ def _parse_usage( ) -> dict[str, int | float | None]: """Extract usage data from Responses API response.""" usage_data = response_data.get("usage", {}) - return OpenResponsesClient.map_usage_fields(usage_data) + return self.map_usage_fields(usage_data) def _parse_content(self, response_data: dict[str, Any]) -> Any: """Parse output array from Responses API.""" diff --git a/src/celeste/providers/deepseek/chat/client.py b/src/celeste/providers/deepseek/chat/client.py index 9d12ab87..41e74bc4 100644 --- a/src/celeste/providers/deepseek/chat/client.py +++ b/src/celeste/providers/deepseek/chat/client.py @@ -53,12 +53,5 @@ def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | float | None ), } - def _parse_usage( - self, response_data: dict[str, Any] - ) -> dict[str, int | float | None]: - """Extract usage data from Chat API response.""" - usage_data = response_data.get("usage", {}) - return DeepSeekChatClient.map_usage_fields(usage_data) - __all__ = ["DeepSeekChatClient"] diff --git a/src/celeste/providers/deepseek/chat/parameters.py b/src/celeste/providers/deepseek/chat/parameters.py deleted file mode 100644 index 92c17650..00000000 --- a/src/celeste/providers/deepseek/chat/parameters.py +++ /dev/null @@ -1,61 +0,0 @@ -"""DeepSeek Chat API parameter mappers.""" - -import json -from typing import Any, get_origin - -from pydantic import BaseModel, TypeAdapter - -from celeste.models import Model -from celeste.parameters import ParameterMapper -from celeste.types import TextContent - - -class ResponseFormatMapper(ParameterMapper): - """Map output_schema to DeepSeek response_format field. - - DeepSeek supports basic JSON mode only (no schema validation server-side). - Schema validation happens client-side via parse_output method. - """ - - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Transform output_schema into provider request.""" - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - request["response_format"] = {"type": "json_object"} - return request - - def parse_output(self, content: TextContent, value: object | None) -> TextContent: - """Parse JSON to BaseModel using Pydantic's TypeAdapter.""" - if value is None: - return content - - # If content is already a BaseModel, return it unchanged - if isinstance(content, BaseModel): - return content - if isinstance(content, list) and content and isinstance(content[0], BaseModel): - return content - - if isinstance(content, str): - parsed = json.loads(content, strict=False) - else: - parsed = content - - # For list[T], handle various formats DeepSeek might return - origin = get_origin(value) - if origin is list and isinstance(parsed, dict): - if "items" in parsed: - parsed = parsed["items"] - else: - parsed = list(parsed.values()) - - return TypeAdapter(value).validate_python(parsed) - - -__all__ = ["ResponseFormatMapper"] diff --git a/src/celeste/providers/groq/chat/parameters.py b/src/celeste/providers/groq/chat/parameters.py index 00be580c..ee58e5b2 100644 --- a/src/celeste/providers/groq/chat/parameters.py +++ b/src/celeste/providers/groq/chat/parameters.py @@ -1,17 +1,17 @@ """Groq Chat API parameter mappers.""" -import json from typing import Any, get_args, get_origin -from pydantic import BaseModel, TypeAdapter +from pydantic import TypeAdapter from celeste.models import Model -from celeste.parameters import ParameterMapper +from celeste.protocols.chatcompletions.parameters import ( + ResponseFormatMapper as _ResponseFormatMapper, +) from celeste.structured_outputs import StrictJsonSchemaGenerator -from celeste.types import TextContent -class ResponseFormatMapper(ParameterMapper): +class ResponseFormatMapper(_ResponseFormatMapper): """Map output_schema to Groq response_format field (json_schema mode). Handles both single BaseModel and list[BaseModel] types. @@ -61,27 +61,5 @@ def map( } return request - def parse_output(self, content: TextContent, value: object | None) -> TextContent: - """Parse JSON to BaseModel using Pydantic's TypeAdapter.""" - if value is None: - return content - - # If content is already a BaseModel, return it unchanged - if isinstance(content, BaseModel): - return content - if isinstance(content, list) and content and isinstance(content[0], BaseModel): - return content - - parsed_json = ( - json.loads(content, strict=False) if isinstance(content, str) else content - ) - - # Unwrap list from items wrapper - origin = get_origin(value) - if origin is list and isinstance(parsed_json, dict) and "items" in parsed_json: - parsed_json = parsed_json["items"] - - return TypeAdapter(value).validate_python(parsed_json) - __all__ = ["ResponseFormatMapper"] diff --git a/src/celeste/providers/mistral/chat/parameters.py b/src/celeste/providers/mistral/chat/parameters.py index c44f636a..22e24c16 100644 --- a/src/celeste/providers/mistral/chat/parameters.py +++ b/src/celeste/providers/mistral/chat/parameters.py @@ -1,14 +1,15 @@ """Mistral Chat API parameter mappers.""" -import json from typing import Any, get_args, get_origin -from pydantic import BaseModel, TypeAdapter +from pydantic import TypeAdapter from celeste.models import Model from celeste.parameters import ParameterMapper +from celeste.protocols.chatcompletions.parameters import ( + ResponseFormatMapper as _ResponseFormatMapper, +) from celeste.structured_outputs import StrictRefResolvingJsonSchemaGenerator -from celeste.types import TextContent class WebSearchMapper(ParameterMapper): @@ -29,7 +30,7 @@ def map( return request -class ResponseFormatMapper(ParameterMapper): +class ResponseFormatMapper(_ResponseFormatMapper): """Map output_schema to Mistral response_format field. Handles both single BaseModel and list[BaseModel] types. @@ -73,22 +74,5 @@ def map( } return request - def parse_output(self, content: TextContent, value: object | None) -> TextContent: - """Parse JSON to BaseModel using Pydantic's TypeAdapter.""" - if value is None: - return content - - # If content is already a BaseModel, return it unchanged - if isinstance(content, BaseModel): - return content - if isinstance(content, list) and content and isinstance(content[0], BaseModel): - return content - - if isinstance(content, str): - parsed = json.loads(content, strict=False) - else: - parsed = content - return TypeAdapter(value).validate_python(parsed) - __all__ = ["ResponseFormatMapper", "WebSearchMapper"] diff --git a/src/celeste/providers/moonshot/chat/parameters.py b/src/celeste/providers/moonshot/chat/parameters.py deleted file mode 100644 index 6a87def9..00000000 --- a/src/celeste/providers/moonshot/chat/parameters.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Moonshot Chat API parameter mappers.""" - -import json -from typing import Any, get_origin - -from pydantic import BaseModel, TypeAdapter - -from celeste.models import Model -from celeste.parameters import ParameterMapper -from celeste.types import TextContent - - -class ResponseFormatMapper(ParameterMapper): - """Map output_schema to Moonshot response_format field. - - Moonshot supports basic JSON mode only (no schema validation server-side). - Schema validation happens client-side via parse_output method. - """ - - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Transform output_schema into provider request.""" - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - request["response_format"] = {"type": "json_object"} - return request - - def parse_output(self, content: TextContent, value: object | None) -> TextContent: - """Parse JSON to BaseModel using Pydantic's TypeAdapter.""" - if value is None: - return content - - # If content is already a BaseModel, return it unchanged - if isinstance(content, BaseModel): - return content - if isinstance(content, list) and content and isinstance(content[0], BaseModel): - return content - - if isinstance(content, str): - parsed = json.loads(content, strict=False) - else: - parsed = content - - # For list[T], handle various formats Moonshot might return - origin = get_origin(value) - if origin is list and isinstance(parsed, dict): - if "items" in parsed: - parsed = parsed["items"] - else: - parsed = list(parsed.values()) - - return TypeAdapter(value).validate_python(parsed) - - -__all__ = ["ResponseFormatMapper"] diff --git a/src/celeste/providers/moonshot/chat/streaming.py b/src/celeste/providers/moonshot/chat/streaming.py index e2e45fc6..326dd30d 100644 --- a/src/celeste/providers/moonshot/chat/streaming.py +++ b/src/celeste/providers/moonshot/chat/streaming.py @@ -2,9 +2,10 @@ from typing import Any -from celeste.protocols.chatcompletions import ChatCompletionsStream - -from .client import MoonshotChatClient +from celeste.protocols.chatcompletions import ( + ChatCompletionsClient, + ChatCompletionsStream, +) class MoonshotChatStream(ChatCompletionsStream): @@ -26,7 +27,7 @@ def _parse_chunk_usage( usage_data = choices[0].get("usage") if isinstance(usage_data, dict): - return MoonshotChatClient.map_usage_fields(usage_data) + return ChatCompletionsClient.map_usage_fields(usage_data) return None diff --git a/templates/protocols/{protocol_slug}/client.py.template b/templates/protocols/{protocol_slug}/client.py.template index 8c187dac..0c72fbc0 100644 --- a/templates/protocols/{protocol_slug}/client.py.template +++ b/templates/protocols/{protocol_slug}/client.py.template @@ -131,7 +131,7 @@ class {Protocol}Client(APIMixin): ) -> dict[str, int | float | None]: """Extract usage data from {Protocol} API response.""" usage_data = response_data.get("usage", {}) - return {Protocol}Client.map_usage_fields(usage_data) + return self.map_usage_fields(usage_data) def _parse_content(self, response_data: dict[str, Any]) -> Any: """Parse content from {Protocol} API response.""" diff --git a/templates/protocols/{protocol_slug}/parameters.py.template b/templates/protocols/{protocol_slug}/parameters.py.template index 7ada0a16..a651765c 100644 --- a/templates/protocols/{protocol_slug}/parameters.py.template +++ b/templates/protocols/{protocol_slug}/parameters.py.template @@ -44,4 +44,32 @@ from celeste.parameters import ParameterMapper # return request +# PATTERN 3: Response format with parse_output +# class ResponseFormatMapper(ParameterMapper): +# """Map output_schema to {Protocol} response_format field.""" +# +# def map( +# self, +# request: dict[str, Any], +# value: object, +# model: Model, +# ) -> dict[str, Any]: +# """Transform output_schema into provider request.""" +# validated_value = self._validate_value(value, model) +# if validated_value is None: +# return request +# +# request["response_format"] = {"type": "json_object"} +# return request +# +# def parse_output(self, content: TextContent, value: object | None) -> TextContent: +# """Parse JSON string to typed output via Pydantic TypeAdapter.""" +# if value is None: +# return content +# if isinstance(content, BaseModel): +# return content +# parsed = json.loads(content, strict=False) if isinstance(content, str) else content +# return TypeAdapter(value).validate_python(parsed) + + __all__: list[str] = [] From 4bb791383ce02645809a5cb9db786638ceb1a64f Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Fri, 20 Feb 2026 20:54:48 +0100 Subject: [PATCH 5/5] fix: add --comment flag to claude code review action Also set pull-requests: write permission to allow posting comments. --- .github/workflows/claude-code-review.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index a4f176af..d3b9bedc 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - pull-requests: read + pull-requests: write issues: read id-token: write @@ -39,6 +39,6 @@ jobs: plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' plugins: 'code-review@claude-code-plugins' claude_args: '--model claude-opus-4-6' - prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' + prompt: '/code-review:code-review --comment ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options