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 diff --git a/src/celeste/modalities/text/providers/deepseek/parameters.py b/src/celeste/modalities/text/providers/deepseek/parameters.py index 02b9462b..4dd6bd27 100644 --- a/src/celeste/modalities/text/providers/deepseek/parameters.py +++ b/src/celeste/modalities/text/providers/deepseek/parameters.py @@ -1,13 +1,13 @@ """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 ( +from celeste.protocols.chatcompletions.parameters import ( ResponseFormatMapper as _ResponseFormatMapper, ) -from celeste.providers.deepseek.chat.parameters import ( +from celeste.protocols.chatcompletions.parameters import ( TemperatureMapper as _TemperatureMapper, ) 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..3a232519 100644 --- a/src/celeste/modalities/text/providers/moonshot/parameters.py +++ b/src/celeste/modalities/text/providers/moonshot/parameters.py @@ -1,13 +1,13 @@ """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 ( +from celeste.protocols.chatcompletions.parameters import ( ResponseFormatMapper as _ResponseFormatMapper, ) -from celeste.providers.moonshot.chat.parameters import ( +from celeste.protocols.chatcompletions.parameters import ( TemperatureMapper as _TemperatureMapper, ) 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..c75e803d --- /dev/null +++ b/src/celeste/protocols/chatcompletions/client.py @@ -0,0 +1,162 @@ +"""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: 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_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: + 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 Chat Completions 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 Chat Completions 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 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 self.map_usage_fields(usage_data) + + def _parse_content(self, response_data: dict[str, Any]) -> Any: + """Parse choices array from Chat Completions API 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 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/providers/deepseek/chat/parameters.py b/src/celeste/protocols/chatcompletions/parameters.py similarity index 80% rename from src/celeste/providers/deepseek/chat/parameters.py rename to src/celeste/protocols/chatcompletions/parameters.py index 7d6f084d..c8fc8d4b 100644 --- a/src/celeste/providers/deepseek/chat/parameters.py +++ b/src/celeste/protocols/chatcompletions/parameters.py @@ -1,4 +1,4 @@ -"""DeepSeek Chat API parameter mappers.""" +"""Chat Completions protocol parameter mappers.""" import json from typing import Any, get_origin @@ -11,7 +11,7 @@ class TemperatureMapper(ParameterMapper): - """Map temperature to DeepSeek temperature field.""" + """Map temperature to Chat Completions temperature field.""" def map( self, @@ -29,7 +29,7 @@ def map( class MaxTokensMapper(ParameterMapper): - """Map max_tokens to DeepSeek max_tokens field.""" + """Map max_tokens to Chat Completions max_tokens field.""" def map( self, @@ -47,10 +47,10 @@ def map( class ResponseFormatMapper(ParameterMapper): - """Map output_schema to DeepSeek response_format field. + """Map output_schema to Chat Completions response_format field. - DeepSeek supports basic JSON mode only (no schema validation server-side). - Schema validation happens client-side via parse_output method. + Default uses json_object mode (no schema validation server-side). + Providers with json_schema support override map() only. """ def map( @@ -68,11 +68,10 @@ def map( return request def parse_output(self, content: TextContent, value: object | None) -> TextContent: - """Parse JSON to BaseModel using Pydantic's TypeAdapter.""" + """Parse JSON string to typed output via Pydantic 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): @@ -83,7 +82,6 @@ def parse_output(self, content: TextContent, value: object | None) -> TextConten 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: diff --git a/src/celeste/protocols/chatcompletions/streaming.py b/src/celeste/protocols/chatcompletions/streaming.py new file mode 100644 index 00000000..c68bb69b --- /dev/null +++ b/src/celeste/protocols/chatcompletions/streaming.py @@ -0,0 +1,85 @@ +"""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 + + 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.""" + 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/protocols/openresponses/client.py b/src/celeste/protocols/openresponses/client.py index e6f1d989..96e1854b 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, ) @@ -114,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/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/client.py b/src/celeste/providers/deepseek/chat/client.py index 6d86adeb..41e74bc4 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. @@ -129,40 +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) - - 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..b4ab578e 100644 --- a/src/celeste/providers/groq/chat/client.py +++ b/src/celeste/providers/groq/chat/client.py @@ -1,145 +1,22 @@ """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_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_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..ee58e5b2 100644 --- a/src/celeste/providers/groq/chat/parameters.py +++ b/src/celeste/providers/groq/chat/parameters.py @@ -1,53 +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 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): +class ResponseFormatMapper(_ResponseFormatMapper): """Map output_schema to Groq response_format field (json_schema mode). Handles both single BaseModel and list[BaseModel] types. @@ -97,31 +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__ = [ - "MaxTokensMapper", - "ResponseFormatMapper", - "TemperatureMapper", -] +__all__ = ["ResponseFormatMapper"] 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/parameters.py b/src/celeste/providers/mistral/chat/parameters.py index 8a5e1a84..22e24c16 100644 --- a/src/celeste/providers/mistral/chat/parameters.py +++ b/src/celeste/providers/mistral/chat/parameters.py @@ -1,50 +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 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): @@ -65,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. @@ -109,27 +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__ = [ - "MaxTokensMapper", - "ResponseFormatMapper", - "TemperatureMapper", - "WebSearchMapper", -] +__all__ = ["ResponseFormatMapper", "WebSearchMapper"] 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/parameters.py b/src/celeste/providers/moonshot/chat/parameters.py deleted file mode 100644 index d0ab3eb8..00000000 --- a/src/celeste/providers/moonshot/chat/parameters.py +++ /dev/null @@ -1,97 +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 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. - - 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__ = ["MaxTokensMapper", "ResponseFormatMapper", "TemperatureMapper"] diff --git a/src/celeste/providers/moonshot/chat/streaming.py b/src/celeste/providers/moonshot/chat/streaming.py index 33211514..326dd30d 100644 --- a/src/celeste/providers/moonshot/chat/streaming.py +++ b/src/celeste/providers/moonshot/chat/streaming.py @@ -2,42 +2,19 @@ from typing import Any -from celeste.io import FinishReason +from celeste.protocols.chatcompletions import ( + ChatCompletionsClient, + 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: @@ -50,38 +27,9 @@ 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 - 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..0c72fbc0 --- /dev/null +++ b/templates/protocols/{protocol_slug}/client.py.template @@ -0,0 +1,157 @@ +"""{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 self.map_usage_fields(usage_data) + + def _parse_content(self, response_data: dict[str, Any]) -> Any: + """Parse content from {Protocol} API response.""" + 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}/parameters.py.template b/templates/protocols/{protocol_slug}/parameters.py.template new file mode 100644 index 00000000..a651765c --- /dev/null +++ b/templates/protocols/{protocol_slug}/parameters.py.template @@ -0,0 +1,75 @@ +"""{Protocol} protocol parameter mappers.""" + +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 + + +# 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] = [] 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: