diff --git a/notebooks/working-with-images.ipynb b/notebooks/working-with-images.ipynb new file mode 100644 index 00000000..8c7ca109 --- /dev/null +++ b/notebooks/working-with-images.ipynb @@ -0,0 +1,248 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Celeste AI - Working With Images\n", + "\n", + "Unified interface for image **generation**, **editing**, and **analysis** across providers.\n", + "\n", + "Star on GitHub 👉 [withceleste/celeste-python](https://github.com/withceleste/celeste-python)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "import celeste\n", + "from IPython.display import Image, display" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## Generate\n", + "\n", + "Create images from text prompts." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "img_gen_result = await celeste.images.generate(\n", + " \"A nano banana on the beach\",\n", + " model=\"gemini-2.5-flash-image\",\n", + ")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "display(Image(data=img_gen_result.content.data))" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## Edit\n", + "\n", + "Modify existing images with text instructions." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "img_edit_result = await celeste.images.edit(\n", + " image=img_gen_result.content,\n", + " prompt=\"Make it night time\",\n", + " model=\"gemini-2.5-flash-image\",\n", + ")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "display(Image(data=img_edit_result.content.data))" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## Analyze\n", + "\n", + "Extract information from images." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "analyze_result = await celeste.images.analyze(\n", + " prompt=\"What fruit is in this image and what color is it?\",\n", + " image=img_gen_result.content,\n", + " model=\"gemini-2.5-flash-lite\",\n", + ")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "print(analyze_result.content)" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": { + "ExecuteTime": { + "end_time": "2026-01-12T21:53:45.320259Z", + "start_time": "2026-01-12T21:53:45.313508Z" + } + }, + "source": [ + "---\n", + "\n", + "## Local Generation with Ollama\n", + "\n", + "Generate images locally using Ollama. No API key needed." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "1. Start the server (in a terminal):\n", + "```bash\n", + "ollama serve\n", + "```\n", + "\n", + "2. Pull the image model:\n", + "```bash\n", + "ollama pull x/flux2-klein\n", + "```" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "prompt = \"A blurry iPhone-style photograph showing the window of a moving train. Through the window, a scenic landscape appears: tall green cliffs running alongside a river, with a small European village built on the slopes. The motion blur suggests the train is moving quickly, with soft reflections on the glass, natural daylight, and a casual handheld phone-camera aesthetic. Sharp textures where possible, rich colors, and a realistic sense of depth and distance.\"\n", + "\n", + "local_result = await celeste.images.generate(\n", + " prompt=prompt,\n", + " model=\"x/flux2-klein\",\n", + " provider=\"ollama\",\n", + " steps=1,\n", + ")\n", + "display(Image(data=local_result.content.data))" + ], + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "---\n", + "\n", + "## Streaming (Ollama)\n", + "\n", + "Ollama streams NDJSON progress events. Celeste exposes these as image stream chunks with `metadata` (progress) and a final chunk containing the image." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from tqdm.asyncio import tqdm\n", + "\n", + "steps = 4\n", + "\n", + "stream = celeste.images.stream.generate(\n", + " prompt=prompt,\n", + " model=\"x/flux2-klein\",\n", + " provider=\"ollama\",\n", + " steps=steps,\n", + ")\n", + "\n", + "async for chunk in tqdm(stream, total=steps+1):\n", + " pass\n", + "\n", + "display(Image(data=chunk.content.data))" + ], + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "---\n", + "Star on GitHub 👉 [withceleste/celeste-python](https://github.com/withceleste/celeste-python)" + ] + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": "" + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.3" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/pyproject.toml b/pyproject.toml index 81d2897c..aa6b104f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "celeste-ai" -version = "0.9.2" +version = "0.9.3" 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" diff --git a/src/celeste/__init__.py b/src/celeste/__init__.py index 0d8d1c23..0aa8f9ee 100644 --- a/src/celeste/__init__.py +++ b/src/celeste/__init__.py @@ -113,7 +113,7 @@ def _resolve_model( msg = f"Model '{model}' not registered. Specify 'modality' explicitly." raise ValueError(msg) warnings.warn( - f"Model '{model}' not registered in Celeste for provider {provider.value}. " + f"Model '{model}' not registered in Celeste for provider {provider}. " "Parameter validation disabled.", UserWarning, stacklevel=3, @@ -209,11 +209,12 @@ def create_client( resolved_operation = ( Operation(operation) if isinstance(operation, str) else operation ) + resolved_provider = Provider(provider) if isinstance(provider, str) else provider resolved_model = _resolve_model( modality=resolved_modality, operation=resolved_operation, - provider=provider, + provider=resolved_provider, model=model, ) diff --git a/src/celeste/http.py b/src/celeste/http.py index c4e6cb5b..17c13e20 100644 --- a/src/celeste/http.py +++ b/src/celeste/http.py @@ -191,6 +191,40 @@ async def stream_post( except json.JSONDecodeError: continue # Skip non-JSON control messages (provider-agnostic) + async def stream_post_ndjson( + self, + url: str, + headers: dict[str, str], + json_body: dict[str, Any], + timeout: float = DEFAULT_TIMEOUT, + ) -> AsyncIterator[dict[str, Any]]: + """Stream POST request using NDJSON (newline-delimited JSON). + + Unlike SSE (stream_post), NDJSON returns one JSON object per line. + Used by Ollama's native API. + + Args: + url: API endpoint URL. + headers: HTTP headers (including authentication). + json_body: JSON request body. + timeout: Timeout in seconds (default: DEFAULT_TIMEOUT). + + Yields: + Parsed JSON objects from NDJSON stream. + """ + client = await self._get_client() + async with client.stream( + "POST", + url, + json=json_body, + headers=headers, + timeout=timeout, + ) as response: + response.raise_for_status() + async for line in response.aiter_lines(): + if line: + yield json.loads(line) + async def aclose(self) -> None: """Close HTTP client and cleanup all connections.""" if self._client is not None: diff --git a/src/celeste/modalities/images/parameters.py b/src/celeste/modalities/images/parameters.py index a6a1d012..9b017fc0 100644 --- a/src/celeste/modalities/images/parameters.py +++ b/src/celeste/modalities/images/parameters.py @@ -16,6 +16,7 @@ class ImageParameter(StrEnum): WATERMARK = "watermark" REFERENCE_IMAGES = "reference_images" PROMPT_UPSAMPLING = "prompt_upsampling" + NEGATIVE_PROMPT = "negative_prompt" SEED = "seed" SAFETY_TOLERANCE = "safety_tolerance" OUTPUT_FORMAT = "output_format" @@ -34,6 +35,7 @@ class ImageParameters(Parameters): watermark: bool reference_images: list[ImageArtifact] prompt_upsampling: bool + negative_prompt: str seed: int safety_tolerance: int output_format: str diff --git a/src/celeste/modalities/images/providers/__init__.py b/src/celeste/modalities/images/providers/__init__.py index 3e065734..8540b999 100644 --- a/src/celeste/modalities/images/providers/__init__.py +++ b/src/celeste/modalities/images/providers/__init__.py @@ -6,11 +6,13 @@ from .bfl import BFLImagesClient from .byteplus import BytePlusImagesClient from .google import GoogleImagesClient +from .ollama import OllamaImagesClient from .openai import OpenAIImagesClient PROVIDERS: dict[Provider, type[ImagesClient]] = { Provider.BFL: BFLImagesClient, Provider.BYTEPLUS: BytePlusImagesClient, Provider.GOOGLE: GoogleImagesClient, + Provider.OLLAMA: OllamaImagesClient, Provider.OPENAI: OpenAIImagesClient, } diff --git a/src/celeste/modalities/images/providers/ollama/__init__.py b/src/celeste/modalities/images/providers/ollama/__init__.py new file mode 100644 index 00000000..c93c6b5c --- /dev/null +++ b/src/celeste/modalities/images/providers/ollama/__init__.py @@ -0,0 +1,6 @@ +"""Ollama provider for images modality.""" + +from .client import OllamaImagesClient +from .models import MODELS + +__all__ = ["MODELS", "OllamaImagesClient"] diff --git a/src/celeste/modalities/images/providers/ollama/client.py b/src/celeste/modalities/images/providers/ollama/client.py new file mode 100644 index 00000000..09518dd4 --- /dev/null +++ b/src/celeste/modalities/images/providers/ollama/client.py @@ -0,0 +1,131 @@ +"""Ollama images client.""" + +import base64 +from typing import Any, Unpack + +from celeste.artifacts import ImageArtifact +from celeste.parameters import ParameterMapper +from celeste.providers.ollama.generate import config +from celeste.providers.ollama.generate.client import OllamaGenerateClient +from celeste.providers.ollama.generate.streaming import ( + OllamaGenerateStream as _OllamaGenerateStream, +) + +from ...client import ImagesClient +from ...io import ( + ImageChunk, + ImageFinishReason, + ImageInput, + ImageOutput, + ImageUsage, +) +from ...parameters import ImageParameters +from ...streaming import ImagesStream +from .parameters import OLLAMA_PARAMETER_MAPPERS + + +class OllamaImagesStream(_OllamaGenerateStream, ImagesStream): + """Ollama NDJSON streaming for images.""" + + def _parse_chunk_usage(self, event_data: dict[str, Any]) -> ImageUsage | None: + """Parse and wrap usage from NDJSON event.""" + usage = super()._parse_chunk_usage(event_data) + if usage: + return ImageUsage(**usage) + return None + + def _parse_chunk_finish_reason( + self, event_data: dict[str, Any] + ) -> ImageFinishReason | None: + """Parse and wrap finish reason from NDJSON event.""" + finish_reason = super()._parse_chunk_finish_reason(event_data) + if finish_reason: + return ImageFinishReason(reason=finish_reason.reason) + return None + + def _parse_chunk(self, event_data: dict[str, Any]) -> ImageChunk | None: + """Parse NDJSON event into ImageChunk.""" + b64_image = self._parse_chunk_content(event_data) + + if not b64_image: + return ImageChunk( + content=ImageArtifact(data=b""), + metadata=self._parse_chunk_metadata(event_data), + ) + + image_bytes = base64.b64decode(b64_image) + return ImageChunk( + content=ImageArtifact(data=image_bytes), + finish_reason=self._parse_chunk_finish_reason(event_data), + usage=self._parse_chunk_usage(event_data), + metadata=self._parse_chunk_metadata(event_data), + ) + + def _aggregate_content(self, chunks: list[ImageChunk]) -> ImageArtifact: + """Get final image from chunks.""" + for chunk in reversed(chunks): + if chunk.content is not None: + return chunk.content + msg = "No image in stream" + raise ValueError(msg) + + def _aggregate_event_data(self, chunks: list[ImageChunk]) -> list[dict[str, Any]]: + """Collect metadata from chunks.""" + return [chunk.metadata for chunk in chunks if chunk.metadata] + + +class OllamaImagesClient(OllamaGenerateClient, ImagesClient): + """Ollama images client (generate only, no edit support yet).""" + + @classmethod + def parameter_mappers(cls) -> list[ParameterMapper]: + return OLLAMA_PARAMETER_MAPPERS + + async def generate( + self, + prompt: str, + **parameters: Unpack[ImageParameters], + ) -> ImageOutput: + """Generate images from prompt.""" + inputs = ImageInput(prompt=prompt) + return await self._predict( + inputs, + endpoint=config.OllamaGenerateEndpoint.GENERATE, + **parameters, + ) + + def _init_request(self, inputs: ImageInput) -> dict[str, Any]: + """Build request with prompt.""" + return {"prompt": inputs.prompt} + + def _parse_usage(self, response_data: dict[str, Any]) -> ImageUsage: + """Parse usage from response. + + Ollama image generation doesn't return usage metrics. + """ + return ImageUsage() + + def _parse_content( + self, + response_data: dict[str, Any], + **parameters: Unpack[ImageParameters], + ) -> ImageArtifact: + """Parse content from response. + + Ollama returns base64-encoded image in the 'image' field. + """ + image_b64 = super()._parse_content(response_data) + image_bytes = base64.b64decode(image_b64) + return ImageArtifact(data=image_bytes) + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> ImageFinishReason: + """Parse finish reason from response.""" + finish_reason = super()._parse_finish_reason(response_data) + return ImageFinishReason(reason=finish_reason.reason) + + def _stream_class(self) -> type[ImagesStream]: + """Return the Stream class for Ollama images.""" + return OllamaImagesStream + + +__all__ = ["OllamaImagesClient", "OllamaImagesStream"] diff --git a/src/celeste/modalities/images/providers/ollama/models.py b/src/celeste/modalities/images/providers/ollama/models.py new file mode 100644 index 00000000..be848691 --- /dev/null +++ b/src/celeste/modalities/images/providers/ollama/models.py @@ -0,0 +1,10 @@ +"""Ollama models for images modality. + +Ollama uses dynamic model discovery - models are pulled by name. +Users specify model IDs like 'x/z-image-turbo' or 'gemma3:4b'. +No pre-registered models needed. +""" + +from celeste.models import Model + +MODELS: list[Model] = [] diff --git a/src/celeste/modalities/images/providers/ollama/parameters.py b/src/celeste/modalities/images/providers/ollama/parameters.py new file mode 100644 index 00000000..c91b9e24 --- /dev/null +++ b/src/celeste/modalities/images/providers/ollama/parameters.py @@ -0,0 +1,79 @@ +"""Ollama parameter mappers for images modality.""" + +from typing import Any + +from celeste.models import Model +from celeste.parameters import ParameterMapper +from celeste.providers.ollama.generate.parameters import ( + HeightMapper as _HeightMapper, +) +from celeste.providers.ollama.generate.parameters import ( + NegativePromptMapper as _NegativePromptMapper, +) +from celeste.providers.ollama.generate.parameters import ( + SeedMapper as _SeedMapper, +) +from celeste.providers.ollama.generate.parameters import ( + StepsMapper as _StepsMapper, +) +from celeste.providers.ollama.generate.parameters import ( + WidthMapper as _WidthMapper, +) + +from ...parameters import ImageParameter + + +class AspectRatioMapper(ParameterMapper): + """Map aspect_ratio to Ollama's width and height parameters. + + Parses 'WxH' string and delegates to native mappers. + """ + + name = ImageParameter.ASPECT_RATIO + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform aspect_ratio into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + parts = validated_value.split("x") + width = int(parts[0]) + height = int(parts[1]) + + request = _WidthMapper().map(request, width, model) + request = _HeightMapper().map(request, height, model) + return request + + +class StepsMapper(_StepsMapper): + """Map steps parameter to Ollama's steps field.""" + + name = ImageParameter.STEPS + + +class SeedMapper(_SeedMapper): + """Map seed parameter to Ollama's options.seed field.""" + + name = ImageParameter.SEED + + +class NegativePromptMapper(_NegativePromptMapper): + """Map negative_prompt parameter to Ollama's negative_prompt field.""" + + name = ImageParameter.NEGATIVE_PROMPT + + +OLLAMA_PARAMETER_MAPPERS: list[ParameterMapper] = [ + AspectRatioMapper(), + StepsMapper(), + SeedMapper(), + NegativePromptMapper(), +] + +__all__ = ["OLLAMA_PARAMETER_MAPPERS"] diff --git a/src/celeste/modalities/text/parameters.py b/src/celeste/modalities/text/parameters.py index e40c7dd5..98f6a4bb 100644 --- a/src/celeste/modalities/text/parameters.py +++ b/src/celeste/modalities/text/parameters.py @@ -1,7 +1,7 @@ """Parameters for text modality. Unified parameters for all text operations (generate, analyze). -Model `parameter_constraints` enforces which parameters are valid per model. +Model `parameter_constraints` validates parameter values when defined; unconstrained parameters pass through. """ from enum import StrEnum diff --git a/src/celeste/modalities/text/providers/openresponses/client.py b/src/celeste/modalities/text/providers/openresponses/client.py index de2e6468..295c9abb 100644 --- a/src/celeste/modalities/text/providers/openresponses/client.py +++ b/src/celeste/modalities/text/providers/openresponses/client.py @@ -2,7 +2,6 @@ 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, @@ -21,7 +20,7 @@ TextOutput, TextUsage, ) -from ...parameters import TextParameter, TextParameters +from ...parameters import TextParameters from ...streaming import TextStream from .parameters import OPENRESPONSES_PARAMETER_MAPPERS @@ -91,19 +90,6 @@ def _aggregate_event_data(self, chunks: list[TextChunk]) -> list[dict[str, Any]] 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 diff --git a/src/celeste/parameters.py b/src/celeste/parameters.py index f195be9e..4956996f 100644 --- a/src/celeste/parameters.py +++ b/src/celeste/parameters.py @@ -4,7 +4,6 @@ from enum import StrEnum from typing import Any, TypedDict -from celeste.exceptions import UnsupportedParameterError from celeste.models import Model from celeste.types import TextContent @@ -38,20 +37,13 @@ def parse_output(self, content: TextContent, value: object | None) -> TextConten return content def _validate_value(self, value: Any, model: Model) -> Any: # noqa: ANN401 - """Validate parameter value using model constraint. - - Raises: - UnsupportedParameterError: If parameter is not supported by the model. - """ + """Validate parameter value using model constraint if present, otherwise pass through.""" if value is None: return None constraint = model.parameter_constraints.get(self.name) if constraint is None: - raise UnsupportedParameterError( - parameter=self.name, - model_id=model.id, - ) + return value return constraint(value) diff --git a/src/celeste/providers/ollama/generate/__init__.py b/src/celeste/providers/ollama/generate/__init__.py new file mode 100644 index 00000000..c919d6e6 --- /dev/null +++ b/src/celeste/providers/ollama/generate/__init__.py @@ -0,0 +1,6 @@ +"""Ollama Generate API provider package.""" + +from .client import OllamaGenerateClient +from .config import DEFAULT_BASE_URL + +__all__ = ["DEFAULT_BASE_URL", "OllamaGenerateClient"] diff --git a/src/celeste/providers/ollama/generate/client.py b/src/celeste/providers/ollama/generate/client.py new file mode 100644 index 00000000..8b33935d --- /dev/null +++ b/src/celeste/providers/ollama/generate/client.py @@ -0,0 +1,146 @@ +"""Ollama Generate API client mixin.""" + +import json +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 OllamaGenerateClient(APIMixin): + """Mixin for Ollama Generate API (/api/generate). + + Provides shared HTTP implementation for Ollama's native API: + - _make_request() - HTTP POST to /api/generate + - _make_stream_request() - NDJSON streaming + - _parse_usage() - Extract usage dict from response + - _parse_content() - Extract image from response + - _parse_finish_reason() - Check done field + - _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 stream flag.""" + request_body = super()._build_request( + inputs, extra_body=extra_body, streaming=streaming, **parameters + ) + request_body["model"] = self.model.id + # Ollama defaults to streaming; explicitly disable for non-streaming + request_body["stream"] = streaming + 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 Ollama Generate API.""" + if endpoint is None: + endpoint = config.OllamaGenerateEndpoint.GENERATE + 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) + # NDJSON: Ollama returns progress lines, final line has done=true + image + return json.loads(response.text.strip().splitlines()[-1]) + + 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 NDJSON streaming request to Ollama Generate API.""" + if endpoint is None: + endpoint = config.OllamaGenerateEndpoint.GENERATE + 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_ndjson( + 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 Ollama Generate usage fields to unified names.""" + input_tokens = usage_data.get("prompt_eval_count") + output_tokens = usage_data.get("eval_count") + total_tokens = usage_data.get("total_eval_count") + if ( + total_tokens is None + and input_tokens is not None + and output_tokens is not None + ): + total_tokens = input_tokens + output_tokens + return { + UsageField.INPUT_TOKENS: input_tokens, + UsageField.OUTPUT_TOKENS: output_tokens, + UsageField.TOTAL_TOKENS: total_tokens, + } + + def _parse_usage( + self, response_data: dict[str, Any] + ) -> dict[str, int | float | None]: + """Extract usage data from Ollama Generate API response. + + Ollama image generation doesn't return token usage. + """ + usage_data = response_data.get("usage", response_data) + return OllamaGenerateClient.map_usage_fields(usage_data) + + def _parse_content(self, response_data: dict[str, Any]) -> Any: + """Parse image content from Ollama Generate API response.""" + image = response_data.get("image") + if not image: + msg = "No image in response" + raise ValueError(msg) + return image + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> FinishReason: + """Extract finish reason from Ollama Generate API response.""" + done = response_data.get("done", False) + return FinishReason(reason="completed" if done else None) + + def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: + """Build metadata dictionary, filtering out content fields.""" + content_fields = {"image", "response"} + filtered_data = { + k: v for k, v in response_data.items() if k not in content_fields + } + return super()._build_metadata(filtered_data) + + +__all__ = ["OllamaGenerateClient"] diff --git a/src/celeste/providers/ollama/generate/config.py b/src/celeste/providers/ollama/generate/config.py new file mode 100644 index 00000000..ad7de29c --- /dev/null +++ b/src/celeste/providers/ollama/generate/config.py @@ -0,0 +1,12 @@ +"""Configuration for Ollama Generate API.""" + +from enum import StrEnum + + +class OllamaGenerateEndpoint(StrEnum): + """Endpoints for Ollama Generate API.""" + + GENERATE = "/api/generate" + + +DEFAULT_BASE_URL = "http://localhost:11434" diff --git a/src/celeste/providers/ollama/generate/parameters.py b/src/celeste/providers/ollama/generate/parameters.py new file mode 100644 index 00000000..a348cae5 --- /dev/null +++ b/src/celeste/providers/ollama/generate/parameters.py @@ -0,0 +1,110 @@ +"""Ollama Generate API parameter mappers. + +Naming convention: +- Mapper class name MUST match the provider's API parameter name +- Example: API param "width" → class WidthMapper +- The request key should match the provider's expected field name exactly +""" + +from typing import Any + +from celeste.constraints import Int +from celeste.models import Model +from celeste.parameters import ParameterMapper + + +class WidthMapper(ParameterMapper): + """Map width to Ollama width field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform width into provider request.""" + if value is None: + return request + + request["width"] = Int()(value) + return request + + +class HeightMapper(ParameterMapper): + """Map height to Ollama height field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform height into provider request.""" + if value is None: + return request + + request["height"] = Int()(value) + return request + + +class StepsMapper(ParameterMapper): + """Map steps to Ollama steps field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform steps into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["steps"] = validated_value + return request + + +class SeedMapper(ParameterMapper): + """Map seed to Ollama options.seed field (nested).""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform seed into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request.setdefault("options", {})["seed"] = validated_value + return request + + +class NegativePromptMapper(ParameterMapper): + """Map negative_prompt to Ollama negative_prompt field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform negative_prompt into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["negative_prompt"] = validated_value + return request + + +__all__ = [ + "HeightMapper", + "NegativePromptMapper", + "SeedMapper", + "StepsMapper", + "WidthMapper", +] diff --git a/src/celeste/providers/ollama/generate/streaming.py b/src/celeste/providers/ollama/generate/streaming.py new file mode 100644 index 00000000..103e1aa9 --- /dev/null +++ b/src/celeste/providers/ollama/generate/streaming.py @@ -0,0 +1,48 @@ +"""Ollama Generate NDJSON streaming.""" + +from typing import Any + +from celeste.io import FinishReason + +from .client import OllamaGenerateClient + + +class OllamaGenerateStream: + """Mixin for Ollama Generate NDJSON parsing. + + Provides shared implementation for streaming parsing (provider API level): + - _parse_chunk_content(event_data) - Extract image from NDJSON event + - _parse_chunk_usage(event_data) - Extract usage from NDJSON event + - _parse_chunk_finish_reason(event_data) - Extract finish reason from NDJSON event + - _parse_chunk_metadata(event_data) - Extract progress metadata from NDJSON event + """ + + def _parse_chunk_content(self, event_data: dict[str, Any]) -> str | None: + """Extract base64 image from NDJSON event.""" + return event_data.get("image") + + def _parse_chunk_usage( + self, event_data: dict[str, Any] + ) -> dict[str, int | float | None] | None: + """Extract usage from NDJSON event.""" + if event_data.get("done"): + return OllamaGenerateClient.map_usage_fields(event_data) + return None + + def _parse_chunk_finish_reason( + self, event_data: dict[str, Any] + ) -> FinishReason | None: + """Extract finish reason from NDJSON event.""" + if event_data.get("done"): + return FinishReason(reason="completed") + return None + + def _parse_chunk_metadata(self, event_data: dict[str, Any]) -> dict[str, Any]: + """Extract progress metadata from NDJSON event.""" + return { + "completed": event_data.get("completed"), + "total": event_data.get("total"), + } + + +__all__ = ["OllamaGenerateStream"] diff --git a/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/parameters.py.template b/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/parameters.py.template index 2e8a73c1..12b02307 100644 --- a/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/parameters.py.template +++ b/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/parameters.py.template @@ -1,7 +1,7 @@ """Parameters for {modality} modality. Unified parameters for all {modality} operations (generate, analyze). -Model `parameter_constraints` enforces which parameters are valid per model. +Model `parameter_constraints` validates parameter values when defined; unconstrained parameters pass through. """ from enum import StrEnum diff --git a/tests/unit_tests/test_parameters.py b/tests/unit_tests/test_parameters.py index 2de6cc5f..8741e218 100644 --- a/tests/unit_tests/test_parameters.py +++ b/tests/unit_tests/test_parameters.py @@ -7,7 +7,7 @@ from celeste.constraints import Range, Str from celeste.core import Parameter, Provider -from celeste.exceptions import ConstraintViolationError, UnsupportedParameterError +from celeste.exceptions import ConstraintViolationError from celeste.models import Model from celeste.types import TextContent @@ -108,8 +108,8 @@ def map( result = mapper._validate_value(None, model) assert result is None - def test_validate_value_with_unsupported_parameter_raises(self) -> None: - """Test that _validate_value raises UnsupportedParameterError when parameter not in model.""" + def test_validate_value_with_missing_constraint_passes_through(self) -> None: + """Missing constraints pass through without error.""" from celeste.parameters import ParameterMapper class TestMapper(ParameterMapper): @@ -125,16 +125,13 @@ def map( mapper = TestMapper() model = Model( - id="test-model", + id="test-model-no-constraints", provider=Provider.OPENAI, display_name="Test Model", - parameter_constraints={}, # No constraints = parameter not supported + parameter_constraints={}, # No constraints ) - with pytest.raises( - UnsupportedParameterError, - match="Parameter 'temperature' is not supported by model 'test-model'", - ): - mapper._validate_value(0.7, model) + result = mapper._validate_value(0.7, model) + assert result == 0.7 def test_validate_value_with_valid_constraint_calls_constraint(self) -> None: """Test that _validate_value calls constraint when parameter is supported."""