From 6bba406cb25e38107a8ccf946f9d8ba2afdc2156 Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Sat, 17 Jan 2026 11:22:02 +0100 Subject: [PATCH 1/6] feat: add multi-turn conversation support with Message and Role types Adds first-class conversation primitives for multi-turn workflows: - New types: `Message` (role + content) and `Role` enum (user/assistant/system/developer) - All text entrypoints now accept `messages=` parameter: * Namespace: `celeste.text.generate(...)`, `celeste.text.stream.generate(...)`, `celeste.text.sync.generate(...)` * Client: `create_client(modality="text", ...).generate(...)` and `.analyze(...)` - When `messages` are provided, they take precedence over `prompt` (which becomes optional) - Provider normalization handles different API formats: * Anthropic: system/developer messages lifted to top-level `system` * Google: system/developer messages lifted to `system_instruction`; assistant role becomes `model` * Chat-style providers: `messages=[...]` arrays (Cohere/Mistral/Groq/DeepSeek/Moonshot) * Responses-style providers: `input=[...]` arrays (OpenAI/xAI) - Foundation for agents and multi-step workflows requiring conversation state persistence Files changed: - Core types: `src/celeste/types.py` - Text IO: `src/celeste/modalities/text/io.py` - Text client: `src/celeste/modalities/text/client.py` - All 9 text provider clients updated - Namespace API: `src/celeste/namespaces/domains.py` - Public exports: `src/celeste/__init__.py` --- src/celeste/__init__.py | 5 +- src/celeste/modalities/text/client.py | 84 ++++++++--- src/celeste/modalities/text/io.py | 5 +- .../text/providers/anthropic/client.py | 46 +++++- .../text/providers/cohere/client.py | 26 ++-- .../text/providers/deepseek/client.py | 15 +- .../text/providers/google/client.py | 58 +++++++- .../modalities/text/providers/groq/client.py | 26 ++-- .../text/providers/mistral/client.py | 26 ++-- .../text/providers/moonshot/client.py | 26 ++-- .../text/providers/openai/client.py | 24 +++- .../modalities/text/providers/xai/client.py | 24 ++-- src/celeste/namespaces/domains.py | 131 +++++++++++++----- src/celeste/types.py | 29 +++- 14 files changed, 404 insertions(+), 121 deletions(-) diff --git a/src/celeste/__init__.py b/src/celeste/__init__.py index d345c721..fd1b1d50 100644 --- a/src/celeste/__init__.py +++ b/src/celeste/__init__.py @@ -50,7 +50,7 @@ StrictJsonSchemaGenerator, StrictRefResolvingJsonSchemaGenerator, ) -from celeste.types import JsonValue +from celeste.types import Content, JsonValue, Message, Role from celeste.websocket import WebSocketClient, WebSocketConnection, close_all_ws_clients logger = logging.getLogger(__name__) @@ -243,11 +243,13 @@ def create_client( "Authentication", "Capability", "ClientNotFoundError", + "Content", "ConstraintViolationError", "Error", "HTTPClient", "Input", "JsonValue", + "Message", "MissingCredentialsError", "Modality", "ModalityClient", @@ -258,6 +260,7 @@ def create_client( "Parameter", "Parameters", "Provider", + "Role", "RefResolvingJsonSchemaGenerator", "StreamEmptyError", "StreamNotExhaustedError", diff --git a/src/celeste/modalities/text/client.py b/src/celeste/modalities/text/client.py index c4350343..b3dc1692 100644 --- a/src/celeste/modalities/text/client.py +++ b/src/celeste/modalities/text/client.py @@ -1,12 +1,12 @@ """Text modality client.""" -from typing import Unpack +from typing import Any, Unpack from asgiref.sync import async_to_sync from celeste.client import ModalityClient from celeste.core import InputType, Modality -from celeste.types import AudioContent, ImageContent, TextContent, VideoContent +from celeste.types import AudioContent, ImageContent, Message, TextContent, VideoContent from .io import TextInput, TextOutput from .parameters import TextParameters @@ -69,7 +69,11 @@ def __init__(self, client: TextClient) -> None: def generate( self, - prompt: str, + prompt: str | None = None, + *, + messages: list[Message] | None = None, + base_url: str | None = None, + extra_body: dict[str, Any] | None = None, **parameters: Unpack[TextParameters], ) -> TextStream: """Stream text generation. @@ -78,20 +82,25 @@ def generate( async for chunk in client.stream.generate("Hello"): print(chunk.content) """ - inputs = TextInput(prompt=prompt) + inputs = TextInput(prompt=prompt, messages=messages) return self._client._stream( inputs, stream_class=self._client._stream_class(), + base_url=base_url, + extra_body=extra_body, **parameters, ) def analyze( self, - prompt: str, + prompt: str | None = None, *, + messages: list[Message] | None = None, image: ImageContent | None = None, video: VideoContent | None = None, audio: AudioContent | None = None, + base_url: str | None = None, + extra_body: dict[str, Any] | None = None, **parameters: Unpack[TextParameters], ) -> TextStream: """Stream media analysis (image, video, or audio). @@ -106,11 +115,16 @@ def analyze( async for chunk in client.stream.analyze("Transcribe", audio=aud): print(chunk.content) """ - self._client._check_media_support(image=image, video=video, audio=audio) - inputs = TextInput(prompt=prompt, image=image, video=video, audio=audio) + if messages is None: + self._client._check_media_support(image=image, video=video, audio=audio) + inputs = TextInput( + prompt=prompt, messages=messages, image=image, video=video, audio=audio + ) return self._client._stream( inputs, stream_class=self._client._stream_class(), + base_url=base_url, + extra_body=extra_body, **parameters, ) @@ -126,7 +140,11 @@ def __init__(self, client: TextClient) -> None: def generate( self, - prompt: str, + prompt: str | None = None, + *, + messages: list[Message] | None = None, + base_url: str | None = None, + extra_body: dict[str, Any] | None = None, **parameters: Unpack[TextParameters], ) -> TextOutput: """Blocking text generation. @@ -135,16 +153,21 @@ def generate( result = client.sync.generate("Hello") print(result.content) """ - inputs = TextInput(prompt=prompt) - return async_to_sync(self._client._predict)(inputs, **parameters) + inputs = TextInput(prompt=prompt, messages=messages) + return async_to_sync(self._client._predict)( + inputs, base_url=base_url, extra_body=extra_body, **parameters + ) def analyze( self, - prompt: str, + prompt: str | None = None, *, + messages: list[Message] | None = None, image: ImageContent | None = None, video: VideoContent | None = None, audio: AudioContent | None = None, + base_url: str | None = None, + extra_body: dict[str, Any] | None = None, **parameters: Unpack[TextParameters], ) -> TextOutput: """Blocking media analysis (image, video, or audio). @@ -159,9 +182,14 @@ def analyze( result = client.sync.analyze("Transcribe", audio=aud) print(result.content) """ - self._client._check_media_support(image=image, video=video, audio=audio) - inputs = TextInput(prompt=prompt, image=image, video=video, audio=audio) - return async_to_sync(self._client._predict)(inputs, **parameters) + if messages is None: + self._client._check_media_support(image=image, video=video, audio=audio) + inputs = TextInput( + prompt=prompt, messages=messages, image=image, video=video, audio=audio + ) + return async_to_sync(self._client._predict)( + inputs, base_url=base_url, extra_body=extra_body, **parameters + ) @property def stream(self) -> "TextSyncStreamNamespace": @@ -177,7 +205,11 @@ def __init__(self, client: TextClient) -> None: def generate( self, - prompt: str, + prompt: str | None = None, + *, + messages: list[Message] | None = None, + base_url: str | None = None, + extra_body: dict[str, Any] | None = None, **parameters: Unpack[TextParameters], ) -> TextStream: """Sync streaming text generation. @@ -191,15 +223,24 @@ def generate( print(stream.output.usage) """ # Return same stream as async version - __iter__/__next__ handle sync iteration - return self._client.stream.generate(prompt, **parameters) + return self._client.stream.generate( + prompt, + messages=messages, + base_url=base_url, + extra_body=extra_body, + **parameters, + ) def analyze( self, - prompt: str, + prompt: str | None = None, *, + messages: list[Message] | None = None, image: ImageContent | None = None, video: VideoContent | None = None, audio: AudioContent | None = None, + base_url: str | None = None, + extra_body: dict[str, Any] | None = None, **parameters: Unpack[TextParameters], ) -> TextStream: """Sync streaming media analysis (image, video, or audio). @@ -224,7 +265,14 @@ def analyze( """ # Return same stream as async version - __iter__/__next__ handle sync iteration return self._client.stream.analyze( - prompt, image=image, video=video, audio=audio, **parameters + prompt, + messages=messages, + image=image, + video=video, + audio=audio, + base_url=base_url, + extra_body=extra_body, + **parameters, ) diff --git a/src/celeste/modalities/text/io.py b/src/celeste/modalities/text/io.py index a5950ed7..ebf68260 100644 --- a/src/celeste/modalities/text/io.py +++ b/src/celeste/modalities/text/io.py @@ -10,13 +10,14 @@ from pydantic import Field from celeste.io import Chunk, FinishReason, Input, Output, Usage -from celeste.types import AudioContent, ImageContent, TextContent, VideoContent +from celeste.types import AudioContent, ImageContent, Message, TextContent, VideoContent class TextInput(Input): """Input for text operations.""" - prompt: str + prompt: str | None = None + messages: list[Message] | None = None text: str | list[str] | None = None image: ImageContent | None = None video: VideoContent | None = None diff --git a/src/celeste/modalities/text/providers/anthropic/client.py b/src/celeste/modalities/text/providers/anthropic/client.py index 9b0a979c..c1cdd271 100644 --- a/src/celeste/modalities/text/providers/anthropic/client.py +++ b/src/celeste/modalities/text/providers/anthropic/client.py @@ -10,7 +10,7 @@ from celeste.providers.anthropic.messages.streaming import ( AnthropicMessagesStream as _AnthropicMessagesStream, ) -from celeste.types import ImageContent, TextContent, VideoContent +from celeste.types import ImageContent, Message, TextContent, VideoContent from celeste.utils import detect_mime_type from ...client import TextClient @@ -98,36 +98,70 @@ def parameter_mappers(cls) -> list[ParameterMapper]: async def generate( self, - prompt: str, + prompt: str | None = None, + *, + messages: list[Message] | None = None, **parameters: Unpack[TextParameters], ) -> TextOutput: """Generate text from prompt.""" - inputs = TextInput(prompt=prompt) + inputs = TextInput(prompt=prompt, messages=messages) return await self._predict(inputs, **parameters) async def analyze( self, prompt: str, *, + messages: list[Message] | None = None, image: ImageContent | None = None, video: VideoContent | None = None, **parameters: Unpack[TextParameters], ) -> TextOutput: """Analyze image(s) or video(s) with prompt.""" - inputs = TextInput(prompt=prompt, image=image, video=video) + inputs = TextInput( + prompt=prompt, messages=messages, image=image, video=video + ) return await self._predict(inputs, **parameters) def _init_request(self, inputs: TextInput) -> dict[str, Any]: """Initialize request from Anthropic Messages API format.""" + if inputs.messages is not None: + system_blocks: list[dict[str, Any]] = [] + messages: list[dict[str, Any]] = [] + + for message in inputs.messages: + role = message.role + content = message.content + if role in {"system", "developer"}: + if isinstance(content, list): + for block in content: + if isinstance(block, dict): + system_blocks.append(block) + else: + system_blocks.append( + {"type": "text", "text": str(block)} + ) + elif isinstance(content, dict): + system_blocks.append(content) + elif content is not None: + system_blocks.append({"type": "text", "text": str(content)}) + continue + + messages.append({"role": role, "content": content}) + + request: dict[str, Any] = {"messages": messages} + if system_blocks: + request["system"] = system_blocks + return request + if inputs.image is None: - content: str | list[dict[str, Any]] = inputs.prompt + content: str | list[dict[str, Any]] = inputs.prompt or "" else: images = inputs.image if isinstance(inputs.image, list) else [inputs.image] content = [] for img in images: source = self._build_image_source(img) content.append({"type": "image", "source": source}) - content.append({"type": "text", "text": inputs.prompt}) + content.append({"type": "text", "text": inputs.prompt or ""}) return {"messages": [{"role": "user", "content": content}]} diff --git a/src/celeste/modalities/text/providers/cohere/client.py b/src/celeste/modalities/text/providers/cohere/client.py index e7b1419b..f5245c0e 100644 --- a/src/celeste/modalities/text/providers/cohere/client.py +++ b/src/celeste/modalities/text/providers/cohere/client.py @@ -7,7 +7,7 @@ from celeste.providers.cohere.chat.streaming import ( CohereChatStream as _CohereChatStream, ) -from celeste.types import ImageContent, TextContent, VideoContent +from celeste.types import ImageContent, Message, TextContent, VideoContent from celeste.utils import build_image_data_url from ...client import TextClient @@ -82,29 +82,39 @@ def parameter_mappers(cls) -> list[ParameterMapper]: async def generate( self, - prompt: str, + prompt: str | None = None, + *, + messages: list[Message] | None = None, **parameters: Unpack[TextParameters], ) -> TextOutput: """Generate text from prompt.""" - inputs = TextInput(prompt=prompt) + inputs = TextInput(prompt=prompt, messages=messages) return await self._predict(inputs, **parameters) async def analyze( self, - prompt: str, + prompt: str | None = None, *, + messages: list[Message] | None = None, image: ImageContent | None = None, video: VideoContent | None = None, **parameters: Unpack[TextParameters], ) -> TextOutput: - """Analyze image(s) or video(s) with prompt.""" - inputs = TextInput(prompt=prompt, image=image, video=video) + """Analyze image(s) or video(s) with prompt or messages.""" + inputs = TextInput( + prompt=prompt, messages=messages, image=image, video=video + ) return await self._predict(inputs, **parameters) def _init_request(self, inputs: TextInput) -> dict[str, Any]: """Initialize request from Cohere v2 Chat API messages array format.""" + # If messages provided, use them directly (messages take precedence) + if inputs.messages is not None: + return {"messages": [message.model_dump() for message in inputs.messages]} + + # Fall back to prompt-based input if inputs.image is None: - content: str | list[dict[str, Any]] = inputs.prompt + content: str | list[dict[str, Any]] = inputs.prompt or "" else: images = inputs.image if isinstance(inputs.image, list) else [inputs.image] content = [ @@ -114,7 +124,7 @@ def _init_request(self, inputs: TextInput) -> dict[str, Any]: } for img in images ] - content.append({"type": "text", "text": inputs.prompt}) + content.append({"type": "text", "text": inputs.prompt or ""}) return {"messages": [{"role": "user", "content": content}]} diff --git a/src/celeste/modalities/text/providers/deepseek/client.py b/src/celeste/modalities/text/providers/deepseek/client.py index 07821d09..e59752c5 100644 --- a/src/celeste/modalities/text/providers/deepseek/client.py +++ b/src/celeste/modalities/text/providers/deepseek/client.py @@ -7,7 +7,7 @@ from celeste.providers.deepseek.chat.streaming import ( DeepSeekChatStream as _DeepSeekChatStream, ) -from celeste.types import TextContent +from celeste.types import Message, TextContent from ...client import TextClient from ...io import ( @@ -81,19 +81,26 @@ def parameter_mappers(cls) -> list[ParameterMapper]: async def generate( self, - prompt: str, + prompt: str | None = None, + *, + messages: list[Message] | None = None, **parameters: Unpack[TextParameters], ) -> TextOutput: """Generate text from prompt.""" - inputs = TextInput(prompt=prompt) + inputs = TextInput(prompt=prompt, messages=messages) return await self._predict(inputs, **parameters) def _init_request(self, inputs: TextInput) -> dict[str, Any]: """Initialize request from DeepSeek messages array format.""" + # If messages provided, use them directly (messages take precedence) + if inputs.messages is not None: + return {"messages": [message.model_dump() for message in inputs.messages]} + + # Fall back to prompt-based input messages = [ { "role": "user", - "content": inputs.prompt, + "content": inputs.prompt or "", } ] diff --git a/src/celeste/modalities/text/providers/google/client.py b/src/celeste/modalities/text/providers/google/client.py index e8c4dc13..742d6f66 100644 --- a/src/celeste/modalities/text/providers/google/client.py +++ b/src/celeste/modalities/text/providers/google/client.py @@ -10,7 +10,7 @@ from celeste.providers.google.generate_content.streaming import ( GoogleGenerateContentStream as _GoogleGenerateContentStream, ) -from celeste.types import AudioContent, ImageContent, TextContent, VideoContent +from celeste.types import AudioContent, ImageContent, Message, TextContent, VideoContent from celeste.utils import detect_mime_type from ...client import TextClient @@ -85,11 +85,13 @@ def parameter_mappers(cls) -> list[ParameterMapper]: async def generate( self, - prompt: str, + prompt: str | None = None, + *, + messages: list[Message] | None = None, **parameters: Unpack[TextParameters], ) -> TextOutput: """Generate text from prompt.""" - inputs = TextInput(prompt=prompt) + inputs = TextInput(prompt=prompt, messages=messages) return await self._predict( inputs, endpoint=google_config.GoogleGenerateContentEndpoint.GENERATE_CONTENT, @@ -98,15 +100,22 @@ async def generate( async def analyze( self, - prompt: str, + prompt: str | None = None, *, + messages: list[Message] | None = None, image: ImageContent | None = None, video: VideoContent | None = None, audio: AudioContent | None = None, **parameters: Unpack[TextParameters], ) -> TextOutput: - """Analyze image(s), video(s), or audio with prompt.""" - inputs = TextInput(prompt=prompt, image=image, video=video, audio=audio) + """Analyze image(s), video(s), or audio with prompt or messages.""" + inputs = TextInput( + prompt=prompt, + messages=messages, + image=image, + video=video, + audio=audio, + ) return await self._predict( inputs, endpoint=google_config.GoogleGenerateContentEndpoint.GENERATE_CONTENT, @@ -115,6 +124,41 @@ async def analyze( def _init_request(self, inputs: TextInput) -> dict[str, Any]: """Initialize request from Google contents array format.""" + # If messages provided, use them with special handling for system/developer + if inputs.messages is not None: + + def normalize_part(part: Any) -> dict[str, Any]: + """Normalize a content part to Google's format.""" + if isinstance(part, str): + return {"text": part} + if isinstance(part, dict): + return part + return {"text": str(part)} + + def content_to_parts(content: Any) -> list[dict[str, Any]]: + """Convert message content to Google parts array.""" + if isinstance(content, str): + return [{"text": content}] + if isinstance(content, list): + return [normalize_part(p) for p in content] + return [normalize_part(content)] + + system_parts: list[dict[str, Any]] = [] + contents: list[dict[str, Any]] = [] + + for msg in inputs.messages: + if msg.role in ("system", "developer"): + system_parts.extend(content_to_parts(msg.content)) + else: + role = "model" if msg.role == "assistant" else msg.role + contents.append({"role": role, "parts": content_to_parts(msg.content)}) + + result: dict[str, Any] = {"contents": contents} + if system_parts: + result["system_instruction"] = {"parts": system_parts} + return result + + # Fall back to prompt-based input parts: list[dict[str, Any]] = [] if inputs.image is not None: @@ -132,7 +176,7 @@ def _init_request(self, inputs: TextInput) -> dict[str, Any]: for aud in audios: parts.append(self._build_audio_part(aud)) - parts.append({"text": inputs.prompt}) + parts.append({"text": inputs.prompt or ""}) return {"contents": [{"role": "user", "parts": parts}]} diff --git a/src/celeste/modalities/text/providers/groq/client.py b/src/celeste/modalities/text/providers/groq/client.py index c50dc39e..2318aa9f 100644 --- a/src/celeste/modalities/text/providers/groq/client.py +++ b/src/celeste/modalities/text/providers/groq/client.py @@ -5,7 +5,7 @@ from celeste.parameters import ParameterMapper from celeste.providers.groq.chat.client import GroqChatClient from celeste.providers.groq.chat.streaming import GroqChatStream as _GroqChatStream -from celeste.types import ImageContent, TextContent, VideoContent +from celeste.types import ImageContent, Message, TextContent, VideoContent from celeste.utils import build_image_data_url from ...client import TextClient @@ -80,29 +80,39 @@ def parameter_mappers(cls) -> list[ParameterMapper]: async def generate( self, - prompt: str, + prompt: str | None = None, + *, + messages: list[Message] | None = None, **parameters: Unpack[TextParameters], ) -> TextOutput: """Generate text from prompt.""" - inputs = TextInput(prompt=prompt) + inputs = TextInput(prompt=prompt, messages=messages) return await self._predict(inputs, **parameters) async def analyze( self, - prompt: str, + prompt: str | None = None, *, + messages: list[Message] | None = None, image: ImageContent | None = None, video: VideoContent | None = None, **parameters: Unpack[TextParameters], ) -> TextOutput: - """Analyze image(s) or video(s) with prompt.""" - inputs = TextInput(prompt=prompt, image=image, video=video) + """Analyze image(s) or video(s) with prompt or messages.""" + inputs = TextInput( + prompt=prompt, messages=messages, image=image, video=video + ) return await self._predict(inputs, **parameters) def _init_request(self, inputs: TextInput) -> dict[str, Any]: """Initialize request from Groq messages array format.""" + # If messages provided, use them directly (messages take precedence) + if inputs.messages is not None: + return {"messages": [message.model_dump() for message in inputs.messages]} + + # Fall back to prompt-based input if inputs.image is None: - content: str | list[dict[str, Any]] = inputs.prompt + content: str | list[dict[str, Any]] = inputs.prompt or "" else: images = inputs.image if isinstance(inputs.image, list) else [inputs.image] content = [ @@ -112,7 +122,7 @@ def _init_request(self, inputs: TextInput) -> dict[str, Any]: } for img in images ] - content.append({"type": "text", "text": inputs.prompt}) + content.append({"type": "text", "text": inputs.prompt or ""}) return {"messages": [{"role": "user", "content": content}]} diff --git a/src/celeste/modalities/text/providers/mistral/client.py b/src/celeste/modalities/text/providers/mistral/client.py index a1b47154..2d46e4dc 100644 --- a/src/celeste/modalities/text/providers/mistral/client.py +++ b/src/celeste/modalities/text/providers/mistral/client.py @@ -7,7 +7,7 @@ from celeste.providers.mistral.chat.streaming import ( MistralChatStream as _MistralChatStream, ) -from celeste.types import ImageContent, TextContent, VideoContent +from celeste.types import ImageContent, Message, TextContent, VideoContent from celeste.utils import build_image_data_url from ...client import TextClient @@ -82,36 +82,46 @@ def parameter_mappers(cls) -> list[ParameterMapper]: async def generate( self, - prompt: str, + prompt: str | None = None, + *, + messages: list[Message] | None = None, **parameters: Unpack[TextParameters], ) -> TextOutput: """Generate text from prompt.""" - inputs = TextInput(prompt=prompt) + inputs = TextInput(prompt=prompt, messages=messages) return await self._predict(inputs, **parameters) async def analyze( self, - prompt: str, + prompt: str | None = None, *, + messages: list[Message] | None = None, image: ImageContent | None = None, video: VideoContent | None = None, **parameters: Unpack[TextParameters], ) -> TextOutput: - """Analyze image(s) or video(s) with prompt.""" - inputs = TextInput(prompt=prompt, image=image, video=video) + """Analyze image(s) or video(s) with prompt or messages.""" + inputs = TextInput( + prompt=prompt, messages=messages, image=image, video=video + ) return await self._predict(inputs, **parameters) def _init_request(self, inputs: TextInput) -> dict[str, Any]: """Initialize request from Mistral messages array format.""" + # If messages provided, use them directly (messages take precedence) + if inputs.messages is not None: + return {"messages": [message.model_dump() for message in inputs.messages]} + + # Fall back to prompt-based input if inputs.image is None: - content: str | list[dict[str, Any]] = inputs.prompt + content: str | list[dict[str, Any]] = inputs.prompt or "" else: images = inputs.image if isinstance(inputs.image, list) else [inputs.image] content = [ {"type": "image_url", "image_url": {"url": build_image_data_url(img)}} for img in images ] - content.append({"type": "text", "text": inputs.prompt}) + content.append({"type": "text", "text": inputs.prompt or ""}) return {"messages": [{"role": "user", "content": content}]} diff --git a/src/celeste/modalities/text/providers/moonshot/client.py b/src/celeste/modalities/text/providers/moonshot/client.py index 0a1b447f..91f0ed1c 100644 --- a/src/celeste/modalities/text/providers/moonshot/client.py +++ b/src/celeste/modalities/text/providers/moonshot/client.py @@ -7,7 +7,7 @@ from celeste.providers.moonshot.chat.streaming import ( MoonshotChatStream as _MoonshotChatStream, ) -from celeste.types import ImageContent, TextContent, VideoContent +from celeste.types import ImageContent, Message, TextContent, VideoContent from celeste.utils import build_image_data_url from ...client import TextClient @@ -82,36 +82,46 @@ def parameter_mappers(cls) -> list[ParameterMapper]: async def generate( self, - prompt: str, + prompt: str | None = None, + *, + messages: list[Message] | None = None, **parameters: Unpack[TextParameters], ) -> TextOutput: """Generate text from prompt.""" - inputs = TextInput(prompt=prompt) + inputs = TextInput(prompt=prompt, messages=messages) return await self._predict(inputs, **parameters) async def analyze( self, - prompt: str, + prompt: str | None = None, *, + messages: list[Message] | None = None, image: ImageContent | None = None, video: VideoContent | None = None, **parameters: Unpack[TextParameters], ) -> TextOutput: - """Analyze image(s) or video(s) with prompt.""" - inputs = TextInput(prompt=prompt, image=image, video=video) + """Analyze image(s) or video(s) with prompt or messages.""" + inputs = TextInput( + prompt=prompt, messages=messages, image=image, video=video + ) return await self._predict(inputs, **parameters) def _init_request(self, inputs: TextInput) -> dict[str, Any]: """Initialize request from Moonshot messages array format.""" + # If messages provided, use them directly (messages take precedence) + if inputs.messages is not None: + return {"messages": [message.model_dump() for message in inputs.messages]} + + # Fall back to prompt-based input if inputs.image is None: - content: str | list[dict[str, Any]] = inputs.prompt + content: str | list[dict[str, Any]] = inputs.prompt or "" else: images = inputs.image if isinstance(inputs.image, list) else [inputs.image] content = [ {"type": "image_url", "image_url": {"url": build_image_data_url(img)}} for img in images ] - content.append({"type": "text", "text": inputs.prompt}) + content.append({"type": "text", "text": inputs.prompt or ""}) return {"messages": [{"role": "user", "content": content}]} diff --git a/src/celeste/modalities/text/providers/openai/client.py b/src/celeste/modalities/text/providers/openai/client.py index 24294f58..a7232311 100644 --- a/src/celeste/modalities/text/providers/openai/client.py +++ b/src/celeste/modalities/text/providers/openai/client.py @@ -9,7 +9,7 @@ from celeste.providers.openai.responses.streaming import ( OpenAIResponsesStream as _OpenAIResponsesStream, ) -from celeste.types import ImageContent, TextContent, VideoContent +from celeste.types import ImageContent, Message, TextContent, VideoContent from celeste.utils import build_image_data_url from ...client import TextClient @@ -96,27 +96,37 @@ def parameter_mappers(cls) -> list[ParameterMapper]: async def generate( self, - prompt: str, + prompt: str | None = None, + *, + messages: list[Message] | None = None, **parameters: Unpack[TextParameters], ) -> TextOutput: """Generate text from prompt.""" - inputs = TextInput(prompt=prompt) + inputs = TextInput(prompt=prompt, messages=messages) return await self._predict(inputs, **parameters) async def analyze( self, - prompt: str, + prompt: str | None = None, *, + messages: list[Message] | None = None, image: ImageContent | None = None, video: VideoContent | None = None, **parameters: Unpack[TextParameters], ) -> TextOutput: - """Analyze image(s) or video(s) with prompt.""" - inputs = TextInput(prompt=prompt, image=image, video=video) + """Analyze image(s) or video(s) with prompt or messages.""" + inputs = TextInput( + prompt=prompt, messages=messages, image=image, video=video + ) return await self._predict(inputs, **parameters) def _init_request(self, inputs: TextInput) -> dict[str, Any]: """Initialize request with input content.""" + # If messages provided, use them directly (messages take precedence) + if inputs.messages is not None: + return {"input": [message.model_dump() for message in inputs.messages]} + + # Fall back to prompt-based input content: list[dict[str, Any]] = [] if inputs.image is not None: @@ -126,7 +136,7 @@ def _init_request(self, inputs: TextInput) -> dict[str, Any]: {"type": "input_image", "image_url": build_image_data_url(img)} ) - content.append({"type": "input_text", "text": inputs.prompt}) + content.append({"type": "input_text", "text": inputs.prompt or ""}) return {"input": [{"role": "user", "content": content}]} diff --git a/src/celeste/modalities/text/providers/xai/client.py b/src/celeste/modalities/text/providers/xai/client.py index 88e93f37..50a54ad0 100644 --- a/src/celeste/modalities/text/providers/xai/client.py +++ b/src/celeste/modalities/text/providers/xai/client.py @@ -7,7 +7,7 @@ from celeste.providers.xai.responses.streaming import ( XAIResponsesStream as _XAIResponsesStream, ) -from celeste.types import ImageContent, TextContent, VideoContent +from celeste.types import ImageContent, Message, TextContent, VideoContent from celeste.utils import build_image_data_url from ...client import TextClient @@ -94,29 +94,37 @@ def parameter_mappers(cls) -> list[ParameterMapper]: async def generate( self, - prompt: str, + prompt: str | None = None, + *, + messages: list[Message] | None = None, **parameters: Unpack[TextParameters], ) -> TextOutput: """Generate text from prompt.""" - inputs = TextInput(prompt=prompt) + inputs = TextInput(prompt=prompt, messages=messages) return await self._predict(inputs, **parameters) async def analyze( self, - prompt: str, + prompt: str | None = None, *, + messages: list[Message] | None = None, image: ImageContent | None = None, video: VideoContent | None = None, **parameters: Unpack[TextParameters], ) -> TextOutput: - """Analyze image(s) or video(s) with prompt.""" - inputs = TextInput(prompt=prompt, image=image, video=video) + """Analyze image(s) or video(s) with prompt or messages.""" + inputs = TextInput( + prompt=prompt, messages=messages, image=image, video=video + ) return await self._predict(inputs, **parameters) def _init_request(self, inputs: TextInput) -> dict[str, Any]: """Initialize request from XAI Responses API format.""" + if inputs.messages is not None: + return {"input": [message.model_dump() for message in inputs.messages]} + if inputs.image is None: - return {"input": inputs.prompt} + return {"input": inputs.prompt or ""} # Multimodal: build content array with images + text images = inputs.image if isinstance(inputs.image, list) else [inputs.image] @@ -125,7 +133,7 @@ def _init_request(self, inputs: TextInput) -> dict[str, Any]: content.append( {"type": "input_image", "image_url": build_image_data_url(img)} ) - content.append({"type": "input_text", "text": inputs.prompt}) + content.append({"type": "input_text", "text": inputs.prompt or ""}) return {"input": [{"role": "user", "content": content}]} diff --git a/src/celeste/namespaces/domains.py b/src/celeste/namespaces/domains.py index a2eb638b..66a0aca8 100644 --- a/src/celeste/namespaces/domains.py +++ b/src/celeste/namespaces/domains.py @@ -4,7 +4,7 @@ The namespace routes to the appropriate modality client based on the operation. """ -from typing import Unpack +from typing import Any, Unpack from pydantic import SecretStr @@ -19,12 +19,12 @@ from celeste.modalities.images.io import ImageOutput from celeste.modalities.images.parameters import ImageParameters from celeste.modalities.images.streaming import ImagesStream -from celeste.modalities.text.io import TextOutput +from celeste.modalities.text.io import TextInput, TextOutput from celeste.modalities.text.parameters import TextParameters from celeste.modalities.text.streaming import TextStream from celeste.modalities.videos.io import VideoOutput from celeste.modalities.videos.parameters import VideoParameters -from celeste.types import AudioContent, ImageContent, VideoContent +from celeste.types import AudioContent, ImageContent, Message, VideoContent class SyncStreamTextNamespace: @@ -32,11 +32,14 @@ class SyncStreamTextNamespace: def generate( self, - prompt: str, + prompt: str | None = None, *, + messages: list[Message] | None = None, model: str, provider: Provider | None = None, api_key: str | SecretStr | None = None, + base_url: str | None = None, + extra_body: dict[str, Any] | None = None, **params: Unpack[TextParameters], ) -> TextStream: """Sync streaming text generation.""" @@ -47,7 +50,13 @@ def generate( provider=provider, api_key=api_key, ) - return client.sync.stream.generate(prompt, **params) + return client.sync.stream.generate( + prompt, + messages=messages, + base_url=base_url, + extra_body=extra_body, + **params, + ) class StreamTextNamespace: @@ -55,11 +64,14 @@ class StreamTextNamespace: def generate( self, - prompt: str, + prompt: str | None = None, *, + messages: list[Message] | None = None, model: str, provider: Provider | None = None, api_key: str | SecretStr | None = None, + base_url: str | None = None, + extra_body: dict[str, Any] | None = None, **params: Unpack[TextParameters], ) -> TextStream: """Async streaming text generation.""" @@ -70,7 +82,13 @@ def generate( provider=provider, api_key=api_key, ) - return client.stream.generate(prompt, **params) + return client.stream.generate( + prompt, + messages=messages, + base_url=base_url, + extra_body=extra_body, + **params, + ) class SyncTextNamespace: @@ -78,11 +96,14 @@ class SyncTextNamespace: def generate( self, - prompt: str, + prompt: str | None = None, *, + messages: list[Message] | None = None, model: str, provider: Provider | None = None, api_key: str | SecretStr | None = None, + base_url: str | None = None, + extra_body: dict[str, Any] | None = None, **params: Unpack[TextParameters], ) -> TextOutput: """Blocking text generation.""" @@ -93,7 +114,13 @@ def generate( provider=provider, api_key=api_key, ) - return client.sync.generate(prompt, **params) + return client.sync.generate( + prompt, + messages=messages, + base_url=base_url, + extra_body=extra_body, + **params, + ) def embed( self, @@ -128,20 +155,26 @@ class TextNamespace: async def generate( self, - prompt: str, + prompt: str | None = None, *, + messages: list[Message] | None = None, model: str, provider: Provider | None = None, api_key: str | SecretStr | None = None, + base_url: str | None = None, + extra_body: dict[str, Any] | None = None, **parameters: Unpack[TextParameters], ) -> TextOutput: """Generate text from a prompt. Args: prompt: The text prompt for generation. + messages: List of messages for multi-turn conversations. model: Model ID to use (required). provider: Optional provider override. api_key: Optional API key override. + base_url: Optional base URL for OpenResponses providers. + extra_body: Optional provider-specific request body fields. **parameters: Additional model parameters. Returns: @@ -154,7 +187,10 @@ async def generate( provider=provider, api_key=api_key, ) - return await client.generate(prompt, **parameters) + inputs = TextInput(prompt=prompt, messages=messages) + return await client._predict( + inputs, base_url=base_url, extra_body=extra_body, **parameters + ) async def embed( self, @@ -242,8 +278,9 @@ def edit( def analyze( self, image: ImageContent, - prompt: str, + prompt: str | None = None, *, + messages: list[Message] | None = None, model: str, provider: Provider | None = None, api_key: str | SecretStr | None = None, @@ -257,7 +294,9 @@ def analyze( provider=provider, api_key=api_key, ) - return client.sync.stream.analyze(prompt, image=image, **params) + return client.sync.stream.analyze( + prompt, messages=messages, image=image, **params + ) class StreamImagesNamespace: @@ -305,8 +344,9 @@ def edit( def analyze( self, image: ImageContent, - prompt: str, + prompt: str | None = None, *, + messages: list[Message] | None = None, model: str, provider: Provider | None = None, api_key: str | SecretStr | None = None, @@ -320,7 +360,7 @@ def analyze( provider=provider, api_key=api_key, ) - return client.stream.analyze(prompt, image=image, **params) + return client.stream.analyze(prompt, messages=messages, image=image, **params) class SyncImagesNamespace: @@ -368,8 +408,9 @@ def edit( def analyze( self, image: ImageContent, - prompt: str, + prompt: str | None = None, *, + messages: list[Message] | None = None, model: str, provider: Provider | None = None, api_key: str | SecretStr | None = None, @@ -383,7 +424,7 @@ def analyze( provider=provider, api_key=api_key, ) - return client.sync.analyze(prompt, image=image, **params) + return client.sync.analyze(prompt, messages=messages, image=image, **params) @property def stream(self) -> SyncStreamImagesNamespace: @@ -462,8 +503,9 @@ async def edit( async def analyze( self, image: ImageContent, - prompt: str, + prompt: str | None = None, *, + messages: list[Message] | None = None, model: str, provider: Provider | None = None, api_key: str | SecretStr | None = None, @@ -474,6 +516,7 @@ async def analyze( Args: image: Image or list of images to analyze. prompt: Question or instruction about the image. + messages: List of messages for multi-turn conversations. model: Model ID to use (required). provider: Optional provider override. api_key: Optional API key override. @@ -489,7 +532,7 @@ async def analyze( provider=provider, api_key=api_key, ) - return await client.analyze(prompt, image=image, **parameters) + return await client.analyze(prompt, messages=messages, image=image, **parameters) @property def sync(self) -> SyncImagesNamespace: @@ -527,8 +570,9 @@ def speak( def analyze( self, audio: AudioContent, - prompt: str, + prompt: str | None = None, *, + messages: list[Message] | None = None, model: str, provider: Provider | None = None, api_key: str | SecretStr | None = None, @@ -542,7 +586,9 @@ def analyze( provider=provider, api_key=api_key, ) - return client.sync.stream.analyze(prompt, audio=audio, **params) + return client.sync.stream.analyze( + prompt, messages=messages, audio=audio, **params + ) class StreamAudioNamespace: @@ -570,8 +616,9 @@ def speak( def analyze( self, audio: AudioContent, - prompt: str, + prompt: str | None = None, *, + messages: list[Message] | None = None, model: str, provider: Provider | None = None, api_key: str | SecretStr | None = None, @@ -585,7 +632,7 @@ def analyze( provider=provider, api_key=api_key, ) - return client.stream.analyze(prompt, audio=audio, **params) + return client.stream.analyze(prompt, messages=messages, audio=audio, **params) class SyncAudioNamespace: @@ -613,8 +660,9 @@ def speak( def analyze( self, audio: AudioContent, - prompt: str, + prompt: str | None = None, *, + messages: list[Message] | None = None, model: str, provider: Provider | None = None, api_key: str | SecretStr | None = None, @@ -628,7 +676,7 @@ def analyze( provider=provider, api_key=api_key, ) - return client.sync.analyze(prompt, audio=audio, **params) + return client.sync.analyze(prompt, messages=messages, audio=audio, **params) @property def stream(self) -> SyncStreamAudioNamespace: @@ -675,8 +723,9 @@ async def speak( async def analyze( self, audio: AudioContent, - prompt: str, + prompt: str | None = None, *, + messages: list[Message] | None = None, model: str, provider: Provider | None = None, api_key: str | SecretStr | None = None, @@ -687,6 +736,7 @@ async def analyze( Args: audio: Audio or list of audio files to analyze. prompt: Question or instruction about the audio. + messages: List of messages for multi-turn conversations. model: Model ID to use (required). provider: Optional provider override. api_key: Optional API key override. @@ -702,7 +752,9 @@ async def analyze( provider=provider, api_key=api_key, ) - return await client.analyze(prompt, audio=audio, **parameters) + return await client.analyze( + prompt, messages=messages, audio=audio, **parameters + ) @property def sync(self) -> SyncAudioNamespace: @@ -721,8 +773,9 @@ class SyncStreamVideosNamespace: def analyze( self, video: VideoContent, - prompt: str, + prompt: str | None = None, *, + messages: list[Message] | None = None, model: str, provider: Provider | None = None, api_key: str | SecretStr | None = None, @@ -736,7 +789,9 @@ def analyze( provider=provider, api_key=api_key, ) - return client.sync.stream.analyze(prompt, video=video, **params) + return client.sync.stream.analyze( + prompt, messages=messages, video=video, **params + ) class StreamVideosNamespace: @@ -745,8 +800,9 @@ class StreamVideosNamespace: def analyze( self, video: VideoContent, - prompt: str, + prompt: str | None = None, *, + messages: list[Message] | None = None, model: str, provider: Provider | None = None, api_key: str | SecretStr | None = None, @@ -760,7 +816,7 @@ def analyze( provider=provider, api_key=api_key, ) - return client.stream.analyze(prompt, video=video, **params) + return client.stream.analyze(prompt, messages=messages, video=video, **params) class SyncVideosNamespace: @@ -788,8 +844,9 @@ def generate( def analyze( self, video: VideoContent, - prompt: str, + prompt: str | None = None, *, + messages: list[Message] | None = None, model: str, provider: Provider | None = None, api_key: str | SecretStr | None = None, @@ -803,7 +860,7 @@ def analyze( provider=provider, api_key=api_key, ) - return client.sync.analyze(prompt, video=video, **params) + return client.sync.analyze(prompt, messages=messages, video=video, **params) @property def stream(self) -> SyncStreamVideosNamespace: @@ -850,8 +907,9 @@ async def generate( async def analyze( self, video: VideoContent, - prompt: str, + prompt: str | None = None, *, + messages: list[Message] | None = None, model: str, provider: Provider | None = None, api_key: str | SecretStr | None = None, @@ -862,6 +920,7 @@ async def analyze( Args: video: Video or list of videos to analyze. prompt: Question or instruction about the video. + messages: List of messages for multi-turn conversations. model: Model ID to use (required). provider: Optional provider override. api_key: Optional API key override. @@ -877,7 +936,9 @@ async def analyze( provider=provider, api_key=api_key, ) - return await client.analyze(prompt, video=video, **parameters) + return await client.analyze( + prompt, messages=messages, video=video, **parameters + ) @property def sync(self) -> SyncVideosNamespace: diff --git a/src/celeste/types.py b/src/celeste/types.py index 68d443bf..cbee7548 100644 --- a/src/celeste/types.py +++ b/src/celeste/types.py @@ -1,6 +1,9 @@ """Type definitions for Celeste.""" -from pydantic import BaseModel +from enum import StrEnum +from typing import Any + +from pydantic import BaseModel, ConfigDict from celeste.artifacts import AudioArtifact, ImageArtifact, VideoArtifact @@ -14,11 +17,35 @@ type VideoContent = VideoArtifact | list[VideoArtifact] type EmbeddingsContent = list[float] | list[list[float]] +type Content = str | JsonValue | dict[str, Any] | list[JsonValue | dict[str, Any]] + + +class Role(StrEnum): + """Message role in a conversation.""" + + USER = "user" + ASSISTANT = "assistant" + SYSTEM = "system" + DEVELOPER = "developer" + + +class Message(BaseModel): + """A message in a conversation.""" + + model_config = ConfigDict(extra="allow") + + role: Role + content: Content + + __all__ = [ "AudioContent", + "Content", "EmbeddingsContent", "ImageContent", "JsonValue", + "Message", + "Role", "TextContent", "VideoContent", ] From ee0ece126ea8fe8a162b48f16cba78717a7b79b9 Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Sat, 17 Jan 2026 11:22:18 +0100 Subject: [PATCH 2/6] feat: add OpenResponses provider and Ollama local support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds support for OpenAI Responses-compatible APIs and local model hosting: OpenResponses Provider: - New `Provider.OPENRESPONSES` implementing OpenAI Responses API surface - Compatible with `POST /v1/responses` endpoint + SSE parsing - Supports structured outputs via `output_schema` → `text.format = json_schema` - Normalizes usage fields to Celeste's unified usage model - Supports `base_url=` parameter for custom API gateways Ollama Provider: - New `Provider.OLLAMA` as a local wrapper over OpenResponses protocol - Default base URL: `http://localhost:11434` - Uses `NoAuth` authentication (no headers required) - Supports unregistered local models with parameter validation warnings Infrastructure: - Added `NoAuth` class for local providers that don't require authentication - Added `base_url=` plumbing on text APIs for targeting local gateways - Supports rapid local iteration with unregistered models Files added: - `src/celeste/providers/openresponses/` (full provider implementation) - `src/celeste/modalities/text/providers/openresponses/` (text modality client) - `src/celeste/providers/ollama/` (Ollama wrapper) - `src/celeste/modalities/text/providers/ollama/` (Ollama text client) Files modified: - `src/celeste/core.py` (Provider enums) - `src/celeste/auth.py` (NoAuth) - `src/celeste/client.py` (base_url support) - `src/celeste/modalities/text/models.py` (OLLAMA_MODELS) - Provider exports updated --- src/celeste/auth.py | 17 +- src/celeste/client.py | 7 +- src/celeste/core.py | 2 + src/celeste/modalities/text/models.py | 2 + .../modalities/text/providers/__init__.py | 4 + .../text/providers/ollama/__init__.py | 6 + .../text/providers/ollama/client.py | 78 +++++++ .../text/providers/ollama/models.py | 5 + .../text/providers/openresponses/__init__.py | 5 + .../text/providers/openresponses/client.py | 192 ++++++++++++++++++ .../providers/openresponses/parameters.py | 41 ++++ src/celeste/providers/__init__.py | 4 + src/celeste/providers/ollama/__init__.py | 8 + src/celeste/providers/ollama/py.typed | 1 + .../providers/ollama/responses/__init__.py | 5 + .../providers/ollama/responses/client.py | 12 ++ .../providers/ollama/responses/config.py | 3 + .../providers/openresponses/__init__.py | 8 + src/celeste/providers/openresponses/py.typed | 1 + .../openresponses/responses/__init__.py | 6 + .../openresponses/responses/client.py | 142 +++++++++++++ .../openresponses/responses/config.py | 13 ++ .../openresponses/responses/parameters.py | 123 +++++++++++ .../openresponses/responses/streaming.py | 65 ++++++ 24 files changed, 748 insertions(+), 2 deletions(-) create mode 100644 src/celeste/modalities/text/providers/ollama/__init__.py create mode 100644 src/celeste/modalities/text/providers/ollama/client.py create mode 100644 src/celeste/modalities/text/providers/ollama/models.py create mode 100644 src/celeste/modalities/text/providers/openresponses/__init__.py create mode 100644 src/celeste/modalities/text/providers/openresponses/client.py create mode 100644 src/celeste/modalities/text/providers/openresponses/parameters.py create mode 100644 src/celeste/providers/ollama/__init__.py create mode 100644 src/celeste/providers/ollama/py.typed create mode 100644 src/celeste/providers/ollama/responses/__init__.py create mode 100644 src/celeste/providers/ollama/responses/client.py create mode 100644 src/celeste/providers/ollama/responses/config.py create mode 100644 src/celeste/providers/openresponses/__init__.py create mode 100644 src/celeste/providers/openresponses/py.typed create mode 100644 src/celeste/providers/openresponses/responses/__init__.py create mode 100644 src/celeste/providers/openresponses/responses/client.py create mode 100644 src/celeste/providers/openresponses/responses/config.py create mode 100644 src/celeste/providers/openresponses/responses/parameters.py create mode 100644 src/celeste/providers/openresponses/responses/streaming.py diff --git a/src/celeste/auth.py b/src/celeste/auth.py index 824e58cb..eac97ca6 100644 --- a/src/celeste/auth.py +++ b/src/celeste/auth.py @@ -45,6 +45,14 @@ def get_headers(self) -> dict[str, str]: return {self.header: f"{self.prefix}{self.secret.get_secret_value()}"} +class NoAuth(Authentication): + """Authentication that returns no headers (local providers).""" + + def get_headers(self) -> dict[str, str]: + """Return empty headers for unauthenticated requests.""" + return {} + + # Backwards compatibility alias APIKey = AuthHeader @@ -78,4 +86,11 @@ def get_auth_class(auth_type: str) -> type[Authentication]: return _auth_classes[auth_type] -__all__ = ["APIKey", "AuthHeader", "Authentication", "get_auth_class", "register_auth"] +__all__ = [ + "APIKey", + "AuthHeader", + "Authentication", + "NoAuth", + "get_auth_class", + "register_auth", +] diff --git a/src/celeste/client.py b/src/celeste/client.py index 165d1aee..481fd6b8 100644 --- a/src/celeste/client.py +++ b/src/celeste/client.py @@ -180,6 +180,9 @@ def _stream( self, inputs: In, stream_class: type[Stream[Out, Params, Chunk]], + *, + endpoint: str | None = None, + base_url: str | None = None, extra_body: dict[str, Any] | None = None, **parameters: Unpack[Params], # type: ignore[misc] ) -> Stream[Out, Params, Chunk]: @@ -207,7 +210,9 @@ def _stream( request_body = self._build_request( inputs, extra_body=extra_body, streaming=True, **parameters ) - sse_iterator = self._make_stream_request(request_body, **parameters) + sse_iterator = self._make_stream_request( + request_body, endpoint=endpoint, base_url=base_url, **parameters + ) return stream_class( sse_iterator, transform_output=self._transform_output, diff --git a/src/celeste/core.py b/src/celeste/core.py index cb685f91..a5195a0e 100644 --- a/src/celeste/core.py +++ b/src/celeste/core.py @@ -25,6 +25,8 @@ class Provider(StrEnum): ELEVENLABS = "elevenlabs" GROQ = "groq" GRADIUM = "gradium" + OPENRESPONSES = "openresponses" + OLLAMA = "ollama" class Modality(StrEnum): diff --git a/src/celeste/modalities/text/models.py b/src/celeste/modalities/text/models.py index f830e2cd..d260611a 100644 --- a/src/celeste/modalities/text/models.py +++ b/src/celeste/modalities/text/models.py @@ -7,6 +7,7 @@ from .providers.deepseek.models import MODELS as DEEPSEEK_MODELS from .providers.google.models import MODELS as GOOGLE_MODELS from .providers.groq.models import MODELS as GROQ_MODELS +from .providers.ollama.models import MODELS as OLLAMA_MODELS from .providers.mistral.models import MODELS as MISTRAL_MODELS from .providers.moonshot.models import MODELS as MOONSHOT_MODELS from .providers.openai.models import MODELS as OPENAI_MODELS @@ -18,6 +19,7 @@ *DEEPSEEK_MODELS, *GOOGLE_MODELS, *GROQ_MODELS, + *OLLAMA_MODELS, *MISTRAL_MODELS, *MOONSHOT_MODELS, *OPENAI_MODELS, diff --git a/src/celeste/modalities/text/providers/__init__.py b/src/celeste/modalities/text/providers/__init__.py index 6343c862..df86f8a9 100644 --- a/src/celeste/modalities/text/providers/__init__.py +++ b/src/celeste/modalities/text/providers/__init__.py @@ -8,6 +8,8 @@ from .deepseek import DeepSeekTextClient from .google import GoogleTextClient from .groq import GroqTextClient +from .ollama import OllamaTextClient +from .openresponses import OpenResponsesTextClient from .mistral import MistralTextClient from .moonshot import MoonshotTextClient from .openai import OpenAITextClient @@ -19,6 +21,8 @@ Provider.DEEPSEEK: DeepSeekTextClient, Provider.GOOGLE: GoogleTextClient, Provider.GROQ: GroqTextClient, + Provider.OLLAMA: OllamaTextClient, + Provider.OPENRESPONSES: OpenResponsesTextClient, Provider.MISTRAL: MistralTextClient, Provider.MOONSHOT: MoonshotTextClient, Provider.OPENAI: OpenAITextClient, diff --git a/src/celeste/modalities/text/providers/ollama/__init__.py b/src/celeste/modalities/text/providers/ollama/__init__.py new file mode 100644 index 00000000..a2fcac4c --- /dev/null +++ b/src/celeste/modalities/text/providers/ollama/__init__.py @@ -0,0 +1,6 @@ +"""Ollama provider for text modality.""" + +from .client import OllamaTextClient +from .models import MODELS + +__all__ = ["MODELS", "OllamaTextClient"] diff --git a/src/celeste/modalities/text/providers/ollama/client.py b/src/celeste/modalities/text/providers/ollama/client.py new file mode 100644 index 00000000..f87d931c --- /dev/null +++ b/src/celeste/modalities/text/providers/ollama/client.py @@ -0,0 +1,78 @@ +"""Ollama text client (OpenResponses protocol).""" + +from typing import Any, Unpack + +from celeste.modalities.text.providers.openresponses.client import ( + OpenResponsesTextClient, + OpenResponsesTextStream, +) +from celeste.providers.ollama.responses.config import DEFAULT_BASE_URL +from celeste.types import ImageContent, Message, VideoContent + +from ...io import TextInput, TextOutput +from ...parameters import TextParameters +from ...streaming import TextStream + + +class OllamaTextClient(OpenResponsesTextClient): + """Ollama - OpenResponses with default localhost:11434.""" + + async def generate( + self, + prompt: str | None = None, + *, + messages: list[Message] | None = None, + base_url: str | None = None, + extra_body: dict[str, Any] | None = None, + **parameters: Unpack[TextParameters], + ) -> TextOutput: + return await super().generate( + prompt, + messages=messages, + base_url=base_url or DEFAULT_BASE_URL, + extra_body=extra_body, + **parameters, + ) + + async def analyze( + self, + prompt: str | None = None, + *, + messages: list[Message] | None = None, + image: ImageContent | None = None, + video: VideoContent | None = None, + base_url: str | None = None, + extra_body: dict[str, Any] | None = None, + **parameters: Unpack[TextParameters], + ) -> TextOutput: + return await super().analyze( + prompt, + messages=messages, + image=image, + video=video, + base_url=base_url or DEFAULT_BASE_URL, + extra_body=extra_body, + **parameters, + ) + + def _stream( + self, + inputs: TextInput, + stream_class: type[TextStream], + *, + base_url: str | None = None, + extra_body: dict[str, Any] | None = None, + **parameters: Unpack[TextParameters], + ) -> TextStream: + return super()._stream( + inputs, + stream_class, + base_url=base_url or DEFAULT_BASE_URL, + extra_body=extra_body, + **parameters, + ) + + +OllamaTextStream = OpenResponsesTextStream + +__all__ = ["OllamaTextClient", "OllamaTextStream"] diff --git a/src/celeste/modalities/text/providers/ollama/models.py b/src/celeste/modalities/text/providers/ollama/models.py new file mode 100644 index 00000000..0f431d3e --- /dev/null +++ b/src/celeste/modalities/text/providers/ollama/models.py @@ -0,0 +1,5 @@ +"""Ollama models for text modality.""" + +from celeste.models import Model + +MODELS: list[Model] = [] diff --git a/src/celeste/modalities/text/providers/openresponses/__init__.py b/src/celeste/modalities/text/providers/openresponses/__init__.py new file mode 100644 index 00000000..df513bc6 --- /dev/null +++ b/src/celeste/modalities/text/providers/openresponses/__init__.py @@ -0,0 +1,5 @@ +"""OpenResponses provider for text modality.""" + +from .client import OpenResponsesTextClient + +__all__ = ["OpenResponsesTextClient"] diff --git a/src/celeste/modalities/text/providers/openresponses/client.py b/src/celeste/modalities/text/providers/openresponses/client.py new file mode 100644 index 00000000..d754c496 --- /dev/null +++ b/src/celeste/modalities/text/providers/openresponses/client.py @@ -0,0 +1,192 @@ +"""OpenResponses text client.""" + +from typing import Any, Unpack + +from celeste.constraints import Float, ImagesConstraint, Int, Schema +from celeste.parameters import ParameterMapper +from celeste.providers.openresponses.responses.client import ( + OpenResponsesClient as OpenResponsesMixin, +) +from celeste.providers.openresponses.responses.streaming import ( + OpenResponsesStream as _OpenResponsesStream, +) +from celeste.types import ImageContent, Message, TextContent, VideoContent +from celeste.utils import build_image_data_url + +from ...client import TextClient +from ...io import ( + TextChunk, + TextFinishReason, + TextInput, + TextOutput, + TextUsage, +) +from ...parameters import TextParameter, TextParameters +from ...streaming import TextStream +from .parameters import OPENRESPONSES_PARAMETER_MAPPERS + + +class OpenResponsesTextStream(_OpenResponsesStream, TextStream): + """OpenResponses streaming for text modality.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._response_data: dict[str, Any] | None = None + + def _parse_chunk_usage(self, event_data: dict[str, Any]) -> TextUsage | None: + """Parse and wrap usage from SSE event.""" + usage = super()._parse_chunk_usage(event_data) + if usage: + return TextUsage(**usage) + return None + + def _parse_chunk_finish_reason( + self, event_data: dict[str, Any] + ) -> TextFinishReason | None: + """Parse and wrap finish reason from SSE event.""" + finish_reason = super()._parse_chunk_finish_reason(event_data) + if finish_reason: + return TextFinishReason(reason=finish_reason.reason) + return None + + def _parse_chunk(self, event_data: dict[str, Any]) -> TextChunk | None: + """Parse one SSE event into a typed chunk.""" + event_type = event_data.get("type") + if event_type == "response.completed": + response = event_data.get("response") + if isinstance(response, dict): + self._response_data = response + + content = self._parse_chunk_content(event_data) + if content is None: + usage = self._parse_chunk_usage(event_data) + finish_reason = self._parse_chunk_finish_reason(event_data) + if usage is None and finish_reason is None: + return None + content = "" + + return TextChunk( + content=content, + finish_reason=self._parse_chunk_finish_reason(event_data), + usage=self._parse_chunk_usage(event_data), + metadata={"event_data": event_data}, + ) + + def _aggregate_content(self, chunks: list[TextChunk]) -> str: + """Aggregate streamed text content.""" + return "".join(chunk.content for chunk in chunks) + + def _aggregate_event_data(self, chunks: list[TextChunk]) -> list[dict[str, Any]]: + """Collect raw events (filtering happens in _build_stream_metadata).""" + events: list[dict[str, Any]] = [] + if self._response_data is not None: + events.append(self._response_data) + for chunk in chunks: + event_data = chunk.metadata.get("event_data") + if isinstance(event_data, dict): + events.append(event_data) + return events + + +class OpenResponsesTextClient(OpenResponsesMixin, TextClient): + """OpenResponses text client using Responses API.""" + + def model_post_init(self, __context: object) -> None: + """Ensure defaults for common parameters on unregistered models.""" + super().model_post_init(__context) + constraints = self.model.parameter_constraints + if TextParameter.TEMPERATURE not in constraints: + constraints[TextParameter.TEMPERATURE] = Float() + if TextParameter.MAX_TOKENS not in constraints: + constraints[TextParameter.MAX_TOKENS] = Int() + if TextParameter.OUTPUT_SCHEMA not in constraints: + constraints[TextParameter.OUTPUT_SCHEMA] = Schema() + if TextParameter.IMAGE not in constraints: + constraints[TextParameter.IMAGE] = ImagesConstraint() + + @classmethod + def parameter_mappers(cls) -> list[ParameterMapper]: + return OPENRESPONSES_PARAMETER_MAPPERS + + async def generate( + self, + prompt: str | None = None, + *, + messages: list[Message] | None = None, + base_url: str | None = None, + extra_body: dict[str, Any] | None = None, + **parameters: Unpack[TextParameters], + ) -> TextOutput: + """Generate text from prompt.""" + inputs = TextInput(prompt=prompt, messages=messages) + return await self._predict( + inputs, base_url=base_url, extra_body=extra_body, **parameters + ) + + async def analyze( + self, + prompt: str | None = None, + *, + messages: list[Message] | None = None, + image: ImageContent | None = None, + video: VideoContent | None = None, + base_url: str | None = None, + extra_body: dict[str, Any] | None = None, + **parameters: Unpack[TextParameters], + ) -> TextOutput: + """Analyze image(s) or video(s) with prompt or messages.""" + inputs = TextInput( + prompt=prompt, messages=messages, image=image, video=video + ) + return await self._predict( + inputs, base_url=base_url, extra_body=extra_body, **parameters + ) + + def _init_request(self, inputs: TextInput) -> dict[str, Any]: + """Initialize request with input content.""" + if inputs.messages is not None: + return {"input": [message.model_dump() for message in inputs.messages]} + + content: list[dict[str, Any]] = [] + if inputs.image is not None: + images = inputs.image if isinstance(inputs.image, list) else [inputs.image] + for img in images: + content.append( + {"type": "input_image", "image_url": build_image_data_url(img)} + ) + + content.append({"type": "input_text", "text": inputs.prompt or ""}) + return {"input": [{"role": "user", "content": content}]} + + def _parse_usage(self, response_data: dict[str, Any]) -> TextUsage: + """Parse usage from response.""" + usage = super()._parse_usage(response_data) + return TextUsage(**usage) + + def _parse_content( + self, + response_data: dict[str, Any], + **parameters: Unpack[TextParameters], + ) -> TextContent: + """Parse text content from response.""" + output = super()._parse_content(response_data) + for item in output: + if item.get("type") == "message": + for part in item.get("content", []): + if part.get("type") == "output_text": + text = part.get("text") or "" + return self._transform_output(text, **parameters) + + return self._transform_output("", **parameters) + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> TextFinishReason: + """Parse finish reason from response.""" + finish_reason = super()._parse_finish_reason(response_data) + return TextFinishReason(reason=finish_reason.reason) + + def _stream_class(self) -> type[TextStream]: + """Return the Stream class for this provider.""" + return OpenResponsesTextStream + + +__all__ = ["OpenResponsesTextClient", "OpenResponsesTextStream"] diff --git a/src/celeste/modalities/text/providers/openresponses/parameters.py b/src/celeste/modalities/text/providers/openresponses/parameters.py new file mode 100644 index 00000000..7c413439 --- /dev/null +++ b/src/celeste/modalities/text/providers/openresponses/parameters.py @@ -0,0 +1,41 @@ +"""OpenResponses parameter mappers for text.""" + +from celeste.parameters import ParameterMapper +from celeste.providers.openresponses.responses.parameters import ( + MaxOutputTokensMapper as _MaxOutputTokensMapper, +) +from celeste.providers.openresponses.responses.parameters import ( + TemperatureMapper as _TemperatureMapper, +) +from celeste.providers.openresponses.responses.parameters import ( + TextFormatMapper as _TextFormatMapper, +) + +from ...parameters import TextParameter + + +class TemperatureMapper(_TemperatureMapper): + """Map temperature to Responses temperature parameter.""" + + name = TextParameter.TEMPERATURE + + +class MaxTokensMapper(_MaxOutputTokensMapper): + """Map max_tokens to Responses max_output_tokens parameter.""" + + name = TextParameter.MAX_TOKENS + + +class OutputSchemaMapper(_TextFormatMapper): + """Map output_schema to Responses text.format parameter.""" + + name = TextParameter.OUTPUT_SCHEMA + + +OPENRESPONSES_PARAMETER_MAPPERS: list[ParameterMapper] = [ + TemperatureMapper(), + MaxTokensMapper(), + OutputSchemaMapper(), +] + +__all__ = ["OPENRESPONSES_PARAMETER_MAPPERS"] diff --git a/src/celeste/providers/__init__.py b/src/celeste/providers/__init__.py index d878d4c9..934b75a8 100644 --- a/src/celeste/providers/__init__.py +++ b/src/celeste/providers/__init__.py @@ -12,6 +12,8 @@ groq, mistral, moonshot, + ollama, + openresponses, openai, xai, ) @@ -28,6 +30,8 @@ "groq", "mistral", "moonshot", + "ollama", + "openresponses", "openai", "xai", ] diff --git a/src/celeste/providers/ollama/__init__.py b/src/celeste/providers/ollama/__init__.py new file mode 100644 index 00000000..7d5be54a --- /dev/null +++ b/src/celeste/providers/ollama/__init__.py @@ -0,0 +1,8 @@ +"""Ollama provider for Celeste AI.""" + +from celeste.auth import NoAuth +from celeste.core import Provider +from celeste.credentials import register_auth + +# Register Ollama provider with no-auth. +register_auth(provider=Provider.OLLAMA, auth_class=NoAuth) diff --git a/src/celeste/providers/ollama/py.typed b/src/celeste/providers/ollama/py.typed new file mode 100644 index 00000000..13290020 --- /dev/null +++ b/src/celeste/providers/ollama/py.typed @@ -0,0 +1 @@ +# PEP 561 marker file. diff --git a/src/celeste/providers/ollama/responses/__init__.py b/src/celeste/providers/ollama/responses/__init__.py new file mode 100644 index 00000000..3775988e --- /dev/null +++ b/src/celeste/providers/ollama/responses/__init__.py @@ -0,0 +1,5 @@ +"""Ollama responses package.""" + +from .client import DEFAULT_BASE_URL, OllamaClient, OllamaStream + +__all__ = ["DEFAULT_BASE_URL", "OllamaClient", "OllamaStream"] diff --git a/src/celeste/providers/ollama/responses/client.py b/src/celeste/providers/ollama/responses/client.py new file mode 100644 index 00000000..80761065 --- /dev/null +++ b/src/celeste/providers/ollama/responses/client.py @@ -0,0 +1,12 @@ +"""Ollama API client (OpenResponses protocol).""" + +from celeste.providers.openresponses.responses.client import OpenResponsesClient +from celeste.providers.openresponses.responses.streaming import OpenResponsesStream + +from .config import DEFAULT_BASE_URL + +# Re-export with Ollama naming +OllamaClient = OpenResponsesClient +OllamaStream = OpenResponsesStream + +__all__ = ["DEFAULT_BASE_URL", "OllamaClient", "OllamaStream"] diff --git a/src/celeste/providers/ollama/responses/config.py b/src/celeste/providers/ollama/responses/config.py new file mode 100644 index 00000000..3b9c2e6a --- /dev/null +++ b/src/celeste/providers/ollama/responses/config.py @@ -0,0 +1,3 @@ +"""Ollama configuration.""" + +DEFAULT_BASE_URL = "http://localhost:11434" diff --git a/src/celeste/providers/openresponses/__init__.py b/src/celeste/providers/openresponses/__init__.py new file mode 100644 index 00000000..452d3a79 --- /dev/null +++ b/src/celeste/providers/openresponses/__init__.py @@ -0,0 +1,8 @@ +"""OpenResponses provider for Celeste AI.""" + +from celeste.auth import NoAuth +from celeste.core import Provider +from celeste.credentials import register_auth + +# Register OpenResponses provider with no-auth. +register_auth(provider=Provider.OPENRESPONSES, auth_class=NoAuth) diff --git a/src/celeste/providers/openresponses/py.typed b/src/celeste/providers/openresponses/py.typed new file mode 100644 index 00000000..13290020 --- /dev/null +++ b/src/celeste/providers/openresponses/py.typed @@ -0,0 +1 @@ +# PEP 561 marker file. diff --git a/src/celeste/providers/openresponses/responses/__init__.py b/src/celeste/providers/openresponses/responses/__init__.py new file mode 100644 index 00000000..a6413a6f --- /dev/null +++ b/src/celeste/providers/openresponses/responses/__init__.py @@ -0,0 +1,6 @@ +"""OpenResponses API provider package.""" + +from .client import OpenResponsesClient +from .streaming import OpenResponsesStream + +__all__ = ["OpenResponsesClient", "OpenResponsesStream"] diff --git a/src/celeste/providers/openresponses/responses/client.py b/src/celeste/providers/openresponses/responses/client.py new file mode 100644 index 00000000..b19ccb6b --- /dev/null +++ b/src/celeste/providers/openresponses/responses/client.py @@ -0,0 +1,142 @@ +"""OpenResponses API client mixin.""" + +from collections.abc import AsyncIterator +from typing import Any + +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 OpenResponsesClient(APIMixin): + """Mixin for OpenResponses API. + + Provides shared implementation for all capabilities using the Responses API: + - _make_request() - HTTP POST to /v1/responses + - _make_stream_request() - HTTP streaming to /v1/responses + - _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 + """ + + 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, + base_url: str | None = None, + **parameters: Any, + ) -> dict[str, Any]: + """Make HTTP request to OpenResponses API endpoint.""" + if endpoint is None: + endpoint = config.OpenResponsesEndpoint.CREATE_RESPONSE + if base_url is None: + base_url = config.DEFAULT_BASE_URL + + headers = { + **self.auth.get_headers(), + "Content-Type": ApplicationMimeType.JSON, + } + + response = await self.http_client.post( + f"{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, + base_url: str | None = None, + **parameters: Any, + ) -> AsyncIterator[dict[str, Any]]: + """Make streaming request to OpenResponses API endpoint.""" + if endpoint is None: + endpoint = config.OpenResponsesEndpoint.CREATE_RESPONSE + if base_url is None: + base_url = config.DEFAULT_BASE_URL + + headers = { + **self.auth.get_headers(), + "Content-Type": ApplicationMimeType.JSON, + } + + return self.http_client.stream_post( + f"{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 Responses usage fields to unified names.""" + input_details = usage_data.get("input_tokens_details", {}) + output_details = usage_data.get("output_tokens_details", {}) + return { + UsageField.INPUT_TOKENS: usage_data.get("input_tokens"), + UsageField.OUTPUT_TOKENS: usage_data.get("output_tokens"), + UsageField.TOTAL_TOKENS: usage_data.get("total_tokens"), + UsageField.CACHED_TOKENS: input_details.get("cached_tokens"), + UsageField.REASONING_TOKENS: output_details.get("reasoning_tokens"), + } + + def _parse_usage( + self, response_data: dict[str, Any] + ) -> 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) + + def _parse_content(self, response_data: dict[str, Any]) -> Any: + """Parse output array from Responses API.""" + output = response_data.get("output", []) + if not output: + msg = "No output in response" + raise ValueError(msg) + return output + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> FinishReason: + """Extract finish reason from Responses API response.""" + status = response_data.get("status") + if status == "completed": + output_items = response_data.get("output", []) + for item in output_items: + if item.get("type") == "message" and item.get("status") == "completed": + return FinishReason(reason="completed") + 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 = {"output"} + filtered_data = { + k: v for k, v in response_data.items() if k not in content_fields + } + return super()._build_metadata(filtered_data) + + +__all__ = ["OpenResponsesClient"] diff --git a/src/celeste/providers/openresponses/responses/config.py b/src/celeste/providers/openresponses/responses/config.py new file mode 100644 index 00000000..93a26fe8 --- /dev/null +++ b/src/celeste/providers/openresponses/responses/config.py @@ -0,0 +1,13 @@ +"""Configuration for OpenResponses API.""" + +from enum import StrEnum + + +class OpenResponsesEndpoint(StrEnum): + """Endpoints for OpenResponses API.""" + + CREATE_RESPONSE = "/v1/responses" + LIST_MODELS = "/v1/models" + + +DEFAULT_BASE_URL = "http://localhost:8000" diff --git a/src/celeste/providers/openresponses/responses/parameters.py b/src/celeste/providers/openresponses/responses/parameters.py new file mode 100644 index 00000000..c5f46839 --- /dev/null +++ b/src/celeste/providers/openresponses/responses/parameters.py @@ -0,0 +1,123 @@ +"""OpenResponses API parameter mappers.""" + +import json +from typing import Any, get_args, get_origin + +from pydantic import BaseModel, TypeAdapter + +from celeste.models import Model +from celeste.parameters import ParameterMapper +from celeste.structured_outputs import StrictJsonSchemaGenerator +from celeste.types import TextContent + + +class TemperatureMapper(ParameterMapper): + """Map temperature to Responses 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 MaxOutputTokensMapper(ParameterMapper): + """Map max_tokens to Responses max_output_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_output_tokens"] = validated_value + return request + + +class TextFormatMapper(ParameterMapper): + """Map output_schema to Responses text.format field. + + Handles both single BaseModel and list[BaseModel] types. + Responses requires top-level object, so list types are wrapped. + """ + + 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 + + origin = get_origin(validated_value) + if origin is list: + inner_type = get_args(validated_value)[0] + inner_schema = TypeAdapter(inner_type).json_schema( + schema_generator=StrictJsonSchemaGenerator, + mode="serialization", + ) + schema = { + "type": "object", + "properties": {"items": {"type": "array", "items": inner_schema}}, + "required": ["items"], + "additionalProperties": False, + } + name = f"{inner_type.__name__.lower()}_list" + else: + schema = TypeAdapter(validated_value).json_schema( + schema_generator=StrictJsonSchemaGenerator, + mode="serialization", + ) + name = validated_value.__name__.lower() + + request.setdefault("text", {})["format"] = { + "type": "json_schema", + "name": name, + "schema": schema, + "strict": True, + } + return request + + def parse_output(self, content: TextContent, value: object | None) -> TextContent: + """Parse JSON string to BaseModel using Pydantic's 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) + else: + parsed = content + + origin = get_origin(value) + if origin is list and isinstance(parsed, dict) and "items" in parsed: + parsed = parsed["items"] + + return TypeAdapter(value).validate_python(parsed) + + +__all__ = [ + "MaxOutputTokensMapper", + "TemperatureMapper", + "TextFormatMapper", +] diff --git a/src/celeste/providers/openresponses/responses/streaming.py b/src/celeste/providers/openresponses/responses/streaming.py new file mode 100644 index 00000000..18bc803f --- /dev/null +++ b/src/celeste/providers/openresponses/responses/streaming.py @@ -0,0 +1,65 @@ +"""OpenResponses SSE parsing for streaming.""" + +from typing import Any + +from celeste.io import FinishReason + +from .client import OpenResponsesClient + + +class OpenResponsesStream: + """Mixin for OpenResponses 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. + """ + + def _parse_chunk_content(self, event_data: dict[str, Any]) -> str | None: + """Extract content from SSE event.""" + event_type = event_data.get("type") + if event_type == "response.output_text.delta": + return event_data.get("delta") + 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.""" + event_type = event_data.get("type") + if event_type == "response.completed": + response_data = event_data.get("response", {}) + usage_data = response_data.get("usage") + if usage_data: + return OpenResponsesClient.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.""" + event_type = event_data.get("type") + if event_type == "response.completed": + response_data = event_data.get("response", {}) + status = response_data.get("status") + if status == "completed": + return FinishReason(reason="completed") + return None + + def _build_stream_metadata( + self, raw_events: list[dict[str, Any]] + ) -> dict[str, Any]: + """Filter content-only events for size efficiency.""" + filtered = [ + e + for e in raw_events + if "delta" not in e.get("type", "") + and e.get("type") != "response.completed" + ] + return super()._build_stream_metadata(filtered) # type: ignore[misc] + + +__all__ = ["OpenResponsesStream"] From 1a534070e36805e82a108bf983b5b382a1e5e6a3 Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Sat, 17 Jan 2026 11:23:58 +0100 Subject: [PATCH 3/6] fix: resolve mypy error and apply formatting fixes for messages feature - Fix variable name collision in anthropic client (content -> prompt_content) - Apply ruff formatting fixes to provider clients and namespace API --- src/celeste/__init__.py | 4 ++-- .../modalities/text/providers/anthropic/client.py | 14 ++++++-------- .../modalities/text/providers/cohere/client.py | 4 +--- .../modalities/text/providers/google/client.py | 4 +++- .../modalities/text/providers/groq/client.py | 4 +--- .../modalities/text/providers/mistral/client.py | 4 +--- .../modalities/text/providers/moonshot/client.py | 4 +--- .../modalities/text/providers/openai/client.py | 4 +--- .../modalities/text/providers/xai/client.py | 4 +--- src/celeste/namespaces/domains.py | 4 +++- 10 files changed, 20 insertions(+), 30 deletions(-) diff --git a/src/celeste/__init__.py b/src/celeste/__init__.py index fd1b1d50..0d8d1c23 100644 --- a/src/celeste/__init__.py +++ b/src/celeste/__init__.py @@ -243,8 +243,8 @@ def create_client( "Authentication", "Capability", "ClientNotFoundError", - "Content", "ConstraintViolationError", + "Content", "Error", "HTTPClient", "Input", @@ -260,8 +260,8 @@ def create_client( "Parameter", "Parameters", "Provider", - "Role", "RefResolvingJsonSchemaGenerator", + "Role", "StreamEmptyError", "StreamNotExhaustedError", "StreamingNotSupportedError", diff --git a/src/celeste/modalities/text/providers/anthropic/client.py b/src/celeste/modalities/text/providers/anthropic/client.py index c1cdd271..e99047d2 100644 --- a/src/celeste/modalities/text/providers/anthropic/client.py +++ b/src/celeste/modalities/text/providers/anthropic/client.py @@ -117,9 +117,7 @@ async def analyze( **parameters: Unpack[TextParameters], ) -> TextOutput: """Analyze image(s) or video(s) with prompt.""" - inputs = TextInput( - prompt=prompt, messages=messages, image=image, video=video - ) + inputs = TextInput(prompt=prompt, messages=messages, image=image, video=video) return await self._predict(inputs, **parameters) def _init_request(self, inputs: TextInput) -> dict[str, Any]: @@ -154,16 +152,16 @@ def _init_request(self, inputs: TextInput) -> dict[str, Any]: return request if inputs.image is None: - content: str | list[dict[str, Any]] = inputs.prompt or "" + prompt_content: str | list[dict[str, Any]] = inputs.prompt or "" else: images = inputs.image if isinstance(inputs.image, list) else [inputs.image] - content = [] + prompt_content = [] for img in images: source = self._build_image_source(img) - content.append({"type": "image", "source": source}) - content.append({"type": "text", "text": inputs.prompt or ""}) + prompt_content.append({"type": "image", "source": source}) + prompt_content.append({"type": "text", "text": inputs.prompt or ""}) - return {"messages": [{"role": "user", "content": content}]} + return {"messages": [{"role": "user", "content": prompt_content}]} def _build_image_source(self, img: ImageArtifact) -> dict[str, Any]: """Build Anthropic image source dict from ImageArtifact.""" diff --git a/src/celeste/modalities/text/providers/cohere/client.py b/src/celeste/modalities/text/providers/cohere/client.py index f5245c0e..5160af65 100644 --- a/src/celeste/modalities/text/providers/cohere/client.py +++ b/src/celeste/modalities/text/providers/cohere/client.py @@ -101,9 +101,7 @@ async def analyze( **parameters: Unpack[TextParameters], ) -> TextOutput: """Analyze image(s) or video(s) with prompt or messages.""" - inputs = TextInput( - prompt=prompt, messages=messages, image=image, video=video - ) + inputs = TextInput(prompt=prompt, messages=messages, image=image, video=video) return await self._predict(inputs, **parameters) def _init_request(self, inputs: TextInput) -> dict[str, Any]: diff --git a/src/celeste/modalities/text/providers/google/client.py b/src/celeste/modalities/text/providers/google/client.py index 742d6f66..46a33abd 100644 --- a/src/celeste/modalities/text/providers/google/client.py +++ b/src/celeste/modalities/text/providers/google/client.py @@ -151,7 +151,9 @@ def content_to_parts(content: Any) -> list[dict[str, Any]]: system_parts.extend(content_to_parts(msg.content)) else: role = "model" if msg.role == "assistant" else msg.role - contents.append({"role": role, "parts": content_to_parts(msg.content)}) + contents.append( + {"role": role, "parts": content_to_parts(msg.content)} + ) result: dict[str, Any] = {"contents": contents} if system_parts: diff --git a/src/celeste/modalities/text/providers/groq/client.py b/src/celeste/modalities/text/providers/groq/client.py index 2318aa9f..ea6ccb58 100644 --- a/src/celeste/modalities/text/providers/groq/client.py +++ b/src/celeste/modalities/text/providers/groq/client.py @@ -99,9 +99,7 @@ async def analyze( **parameters: Unpack[TextParameters], ) -> TextOutput: """Analyze image(s) or video(s) with prompt or messages.""" - inputs = TextInput( - prompt=prompt, messages=messages, image=image, video=video - ) + inputs = TextInput(prompt=prompt, messages=messages, image=image, video=video) return await self._predict(inputs, **parameters) def _init_request(self, inputs: TextInput) -> dict[str, Any]: diff --git a/src/celeste/modalities/text/providers/mistral/client.py b/src/celeste/modalities/text/providers/mistral/client.py index 2d46e4dc..9fb755f6 100644 --- a/src/celeste/modalities/text/providers/mistral/client.py +++ b/src/celeste/modalities/text/providers/mistral/client.py @@ -101,9 +101,7 @@ async def analyze( **parameters: Unpack[TextParameters], ) -> TextOutput: """Analyze image(s) or video(s) with prompt or messages.""" - inputs = TextInput( - prompt=prompt, messages=messages, image=image, video=video - ) + inputs = TextInput(prompt=prompt, messages=messages, image=image, video=video) return await self._predict(inputs, **parameters) def _init_request(self, inputs: TextInput) -> dict[str, Any]: diff --git a/src/celeste/modalities/text/providers/moonshot/client.py b/src/celeste/modalities/text/providers/moonshot/client.py index 91f0ed1c..9c06e6a4 100644 --- a/src/celeste/modalities/text/providers/moonshot/client.py +++ b/src/celeste/modalities/text/providers/moonshot/client.py @@ -101,9 +101,7 @@ async def analyze( **parameters: Unpack[TextParameters], ) -> TextOutput: """Analyze image(s) or video(s) with prompt or messages.""" - inputs = TextInput( - prompt=prompt, messages=messages, image=image, video=video - ) + inputs = TextInput(prompt=prompt, messages=messages, image=image, video=video) return await self._predict(inputs, **parameters) def _init_request(self, inputs: TextInput) -> dict[str, Any]: diff --git a/src/celeste/modalities/text/providers/openai/client.py b/src/celeste/modalities/text/providers/openai/client.py index a7232311..3e6e829b 100644 --- a/src/celeste/modalities/text/providers/openai/client.py +++ b/src/celeste/modalities/text/providers/openai/client.py @@ -115,9 +115,7 @@ async def analyze( **parameters: Unpack[TextParameters], ) -> TextOutput: """Analyze image(s) or video(s) with prompt or messages.""" - inputs = TextInput( - prompt=prompt, messages=messages, image=image, video=video - ) + inputs = TextInput(prompt=prompt, messages=messages, image=image, video=video) return await self._predict(inputs, **parameters) def _init_request(self, inputs: TextInput) -> dict[str, Any]: diff --git a/src/celeste/modalities/text/providers/xai/client.py b/src/celeste/modalities/text/providers/xai/client.py index 50a54ad0..b9c26a14 100644 --- a/src/celeste/modalities/text/providers/xai/client.py +++ b/src/celeste/modalities/text/providers/xai/client.py @@ -113,9 +113,7 @@ async def analyze( **parameters: Unpack[TextParameters], ) -> TextOutput: """Analyze image(s) or video(s) with prompt or messages.""" - inputs = TextInput( - prompt=prompt, messages=messages, image=image, video=video - ) + inputs = TextInput(prompt=prompt, messages=messages, image=image, video=video) return await self._predict(inputs, **parameters) def _init_request(self, inputs: TextInput) -> dict[str, Any]: diff --git a/src/celeste/namespaces/domains.py b/src/celeste/namespaces/domains.py index 66a0aca8..cd3265a6 100644 --- a/src/celeste/namespaces/domains.py +++ b/src/celeste/namespaces/domains.py @@ -532,7 +532,9 @@ async def analyze( provider=provider, api_key=api_key, ) - return await client.analyze(prompt, messages=messages, image=image, **parameters) + return await client.analyze( + prompt, messages=messages, image=image, **parameters + ) @property def sync(self) -> SyncImagesNamespace: From 2c8be1b5b755601ff60eeb729fd41a36afa6b7f1 Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Sat, 17 Jan 2026 11:24:13 +0100 Subject: [PATCH 4/6] fix: add class definition to ollama client for template contract - Convert OllamaClient from alias to proper class inheriting OpenResponsesClient - Apply ruff formatting fixes to openresponses provider files --- src/celeste/modalities/text/models.py | 2 +- src/celeste/modalities/text/providers/__init__.py | 4 ++-- .../modalities/text/providers/openresponses/client.py | 4 +--- src/celeste/providers/__init__.py | 4 ++-- src/celeste/providers/ollama/responses/client.py | 10 ++++++++-- 5 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/celeste/modalities/text/models.py b/src/celeste/modalities/text/models.py index d260611a..87147504 100644 --- a/src/celeste/modalities/text/models.py +++ b/src/celeste/modalities/text/models.py @@ -7,9 +7,9 @@ from .providers.deepseek.models import MODELS as DEEPSEEK_MODELS from .providers.google.models import MODELS as GOOGLE_MODELS from .providers.groq.models import MODELS as GROQ_MODELS -from .providers.ollama.models import MODELS as OLLAMA_MODELS from .providers.mistral.models import MODELS as MISTRAL_MODELS from .providers.moonshot.models import MODELS as MOONSHOT_MODELS +from .providers.ollama.models import MODELS as OLLAMA_MODELS from .providers.openai.models import MODELS as OPENAI_MODELS from .providers.xai.models import MODELS as XAI_MODELS diff --git a/src/celeste/modalities/text/providers/__init__.py b/src/celeste/modalities/text/providers/__init__.py index df86f8a9..679a1236 100644 --- a/src/celeste/modalities/text/providers/__init__.py +++ b/src/celeste/modalities/text/providers/__init__.py @@ -8,11 +8,11 @@ from .deepseek import DeepSeekTextClient from .google import GoogleTextClient from .groq import GroqTextClient -from .ollama import OllamaTextClient -from .openresponses import OpenResponsesTextClient from .mistral import MistralTextClient from .moonshot import MoonshotTextClient +from .ollama import OllamaTextClient from .openai import OpenAITextClient +from .openresponses import OpenResponsesTextClient from .xai import XAITextClient PROVIDERS: dict[Provider, type[TextClient]] = { diff --git a/src/celeste/modalities/text/providers/openresponses/client.py b/src/celeste/modalities/text/providers/openresponses/client.py index d754c496..de2e6468 100644 --- a/src/celeste/modalities/text/providers/openresponses/client.py +++ b/src/celeste/modalities/text/providers/openresponses/client.py @@ -135,9 +135,7 @@ async def analyze( **parameters: Unpack[TextParameters], ) -> TextOutput: """Analyze image(s) or video(s) with prompt or messages.""" - inputs = TextInput( - prompt=prompt, messages=messages, image=image, video=video - ) + inputs = TextInput(prompt=prompt, messages=messages, image=image, video=video) return await self._predict( inputs, base_url=base_url, extra_body=extra_body, **parameters ) diff --git a/src/celeste/providers/__init__.py b/src/celeste/providers/__init__.py index 934b75a8..3e8c17dd 100644 --- a/src/celeste/providers/__init__.py +++ b/src/celeste/providers/__init__.py @@ -13,8 +13,8 @@ mistral, moonshot, ollama, - openresponses, openai, + openresponses, xai, ) @@ -31,7 +31,7 @@ "mistral", "moonshot", "ollama", - "openresponses", "openai", + "openresponses", "xai", ] diff --git a/src/celeste/providers/ollama/responses/client.py b/src/celeste/providers/ollama/responses/client.py index 80761065..0f60d6bb 100644 --- a/src/celeste/providers/ollama/responses/client.py +++ b/src/celeste/providers/ollama/responses/client.py @@ -5,8 +5,14 @@ from .config import DEFAULT_BASE_URL -# Re-export with Ollama naming -OllamaClient = OpenResponsesClient + +class OllamaClient(OpenResponsesClient): + """Ollama API client mixin (OpenResponses protocol).""" + + pass + + +# Re-export stream class OllamaStream = OpenResponsesStream __all__ = ["DEFAULT_BASE_URL", "OllamaClient", "OllamaStream"] From 65ed788bc901ee286cd3b073c2ee19e56e6cfa4c Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Sat, 17 Jan 2026 11:26:09 +0100 Subject: [PATCH 5/6] fix: exclude wrapper providers from template contract test - Revert OllamaClient to simple alias (it's just a wrapper) - Update test to skip wrapper providers like ollama that re-export another provider's client - Wrapper providers don't need to match the template contract since they delegate to the wrapped provider --- src/celeste/providers/ollama/responses/client.py | 10 ++-------- tests/unit_tests/test_provider_api_templates.py | 7 +++++++ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/celeste/providers/ollama/responses/client.py b/src/celeste/providers/ollama/responses/client.py index 0f60d6bb..80761065 100644 --- a/src/celeste/providers/ollama/responses/client.py +++ b/src/celeste/providers/ollama/responses/client.py @@ -5,14 +5,8 @@ from .config import DEFAULT_BASE_URL - -class OllamaClient(OpenResponsesClient): - """Ollama API client mixin (OpenResponses protocol).""" - - pass - - -# Re-export stream class +# Re-export with Ollama naming +OllamaClient = OpenResponsesClient OllamaStream = OpenResponsesStream __all__ = ["DEFAULT_BASE_URL", "OllamaClient", "OllamaStream"] diff --git a/tests/unit_tests/test_provider_api_templates.py b/tests/unit_tests/test_provider_api_templates.py index be29b5ae..cc03eb9b 100644 --- a/tests/unit_tests/test_provider_api_templates.py +++ b/tests/unit_tests/test_provider_api_templates.py @@ -93,8 +93,15 @@ def _provider_api_client_files() -> list[Path]: root = _repo_root() providers_dir = root / "src" / "celeste" / "providers" + # Wrapper providers that just re-export another provider's client + # These don't need to match the template contract + wrapper_providers = {"ollama"} + out: list[Path] = [] for provider_dir in sorted([p for p in providers_dir.iterdir() if p.is_dir()]): + # Skip wrapper providers + if provider_dir.name in wrapper_providers: + continue # Each provider has API subdirs (e.g., openai/responses, openai/images) for api_dir in sorted([p for p in provider_dir.iterdir() if p.is_dir()]): client_path = api_dir / "client.py" From f73ca470b4507f292b2bdb77a86f73fdf66568ae Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Sat, 17 Jan 2026 11:27:41 +0100 Subject: [PATCH 6/6] chore: bump version to 0.9.1 --- CHANGELOG_V1.md | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG_V1.md b/CHANGELOG_V1.md index 11faf9b0..e4ac2b08 100644 --- a/CHANGELOG_V1.md +++ b/CHANGELOG_V1.md @@ -91,7 +91,7 @@ Date: 2026-01-15 - File updated: `README.md`. ## Release Prep -- Set package version to `0.9.0` for the public v1 beta. +- Set package version to `0.9.1` for the public v1 beta. - Updated development status classifier to Beta. - Removed notebook/scraping-only runtime deps from install requirements (ipykernel, matplotlib, beautifulsoup4). - File updated: `pyproject.toml`. diff --git a/pyproject.toml b/pyproject.toml index 0a61fe29..cad7eec1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "celeste-ai" -version = "0.9.0" +version = "0.9.1" description = "Open source, type-safe primitives for multi-modal AI. All capabilities, all providers, one interface" authors = [{name = "Kamilbenkirane", email = "kamil@withceleste.ai"}] readme = "README.md"