From 580bed8dc9d9e5a3b17b2a2d495c186eb3625a7c Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Tue, 4 Nov 2025 11:14:29 +0100 Subject: [PATCH 01/11] feat: add core enums and constraints --- src/__init__.py | 0 src/celeste/constraints.py | 178 +++++++++++++++++++++++++++++++++++++ src/celeste/core.py | 53 +++++++++++ src/celeste/py.typed | 2 + 4 files changed, 233 insertions(+) create mode 100644 src/__init__.py create mode 100644 src/celeste/constraints.py create mode 100644 src/celeste/core.py create mode 100644 src/celeste/py.typed diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/celeste/constraints.py b/src/celeste/constraints.py new file mode 100644 index 00000000..3dc159d0 --- /dev/null +++ b/src/celeste/constraints.py @@ -0,0 +1,178 @@ +"""Constraint models for parameter validation.""" + +import re +from abc import ABC, abstractmethod +from typing import Any, get_args, get_origin + +from pydantic import BaseModel, Field + + +class Constraint(BaseModel, ABC): + """Base constraint for parameter validation.""" + + @abstractmethod + def __call__(self, value: Any) -> Any: # noqa: ANN401 + """Validate value against constraint and return validated value.""" + ... + + +class Choice[T](Constraint): + """Choice constraint - value must be one of the provided options.""" + + options: list[T] = Field(min_length=1) + + def __call__(self, value: T) -> T: + """Validate value is in options.""" + if value not in self.options: + msg = f"Must be one of {self.options}, got {value!r}" + raise ValueError(msg) + return value + + +class Range(Constraint): + """Range constraint - value must be within min/max bounds. + + If step is provided, value must be at min + (n * step) for some integer n. + """ + + min: float | int + max: float | int + step: float | None = None + + def __call__(self, value: float | int) -> float | int: + """Validate value is within range and matches step increment.""" + if not isinstance(value, (int, float)): + msg = f"Must be numeric, got {type(value).__name__}" + raise TypeError(msg) + + if not self.min <= value <= self.max: + msg = f"Must be between {self.min} and {self.max}, got {value}" + raise ValueError(msg) + + if self.step is not None: + remainder = (value - self.min) % self.step + # Use epsilon for floating-point comparison tolerance + epsilon = 1e-9 + if remainder > epsilon and abs(remainder - self.step) > epsilon: + # Calculate nearest valid values for actionable error message + closest_below = self.min + ( + int((value - self.min) / self.step) * self.step + ) + closest_above = closest_below + self.step + msg = f"Value must match step {self.step}. Nearest valid: {closest_below} or {closest_above}, got {value}" + raise ValueError(msg) + + return value + + +class Pattern(Constraint): + """Pattern constraint - value must match regex pattern.""" + + pattern: str + + def __call__(self, value: str) -> str: + """Validate value matches pattern.""" + if not isinstance(value, str): + msg = f"Must be string, got {type(value).__name__}" + raise TypeError(msg) + + if not re.fullmatch(self.pattern, value): + msg = f"Must match pattern {self.pattern!r}, got {value!r}" + raise ValueError(msg) + + return value + + +class Str(Constraint): + """String type constraint with optional length validation.""" + + max_length: int | None = None + min_length: int | None = None + + def __call__(self, value: str) -> str: + """Validate value is a string.""" + if not isinstance(value, str): + msg = f"Must be string, got {type(value).__name__}" + raise TypeError(msg) + + if self.min_length is not None and len(value) < self.min_length: + msg = f"String too short (min {self.min_length}), got {len(value)}" + raise ValueError(msg) + + if self.max_length is not None and len(value) > self.max_length: + msg = f"String too long (max {self.max_length}), got {len(value)}" + raise ValueError(msg) + + return value + + +class Int(Constraint): + """Integer type constraint.""" + + def __call__(self, value: int) -> int: + """Validate value is an integer.""" + # isinstance(True, int) is True, so exclude bools explicitly + if not isinstance(value, int) or isinstance(value, bool): + msg = f"Must be int, got {type(value).__name__}" + raise TypeError(msg) + + return value + + +class Float(Constraint): + """Float type constraint (accepts int as well).""" + + def __call__(self, value: float) -> float: + """Validate value is numeric.""" + if not isinstance(value, (int, float)) or isinstance(value, bool): + msg = f"Must be float or int, got {type(value).__name__}" + raise TypeError(msg) + + return float(value) + + +class Bool(Constraint): + """Boolean type constraint.""" + + def __call__(self, value: bool) -> bool: + """Validate value is a boolean.""" + if not isinstance(value, bool): + msg = f"Must be bool, got {type(value).__name__}" + raise TypeError(msg) + + return value + + +class Schema(Constraint): + """Schema constraint - value must be a Pydantic BaseModel subclass or list[BaseModel].""" + + def __call__(self, value: type[BaseModel]) -> type[BaseModel]: + """Validate value is BaseModel or list[BaseModel].""" + # For list[T], validate inner type T + if get_origin(value) is list: + inner = get_args(value)[0] + if not (isinstance(inner, type) and issubclass(inner, BaseModel)): + msg = f"List type must be BaseModel, got {inner}" + raise TypeError(msg) + return value + + # For plain type, validate directly + if not (isinstance(value, type) and issubclass(value, BaseModel)): + msg = f"Must be BaseModel, got {value}" + raise TypeError(msg) + + return value + + +__all__ = [ + "Bool", + "Choice", + "Constraint", + "Float", + "Int", + "Pattern", + "Range", + "Schema", + "Str", +] + diff --git a/src/celeste/core.py b/src/celeste/core.py new file mode 100644 index 00000000..91b46d99 --- /dev/null +++ b/src/celeste/core.py @@ -0,0 +1,53 @@ +"""Core enumerations for Celeste.""" + +from enum import StrEnum + + +class Provider(StrEnum): + """Supported AI providers.""" + + OPENAI = "openai" + ANTHROPIC = "anthropic" + GOOGLE = "google" + MISTRAL = "mistral" + COHERE = "cohere" + XAI = "xai" + HUGGINGFACE = "huggingface" + REPLICATE = "replicate" + STABILITYAI = "stabilityai" + LUMA = "luma" + TOPAZLABS = "topazlabs" + PERPLEXITY = "perplexity" + + +class Capability(StrEnum): + """Supported AI capabilities.""" + + # Text + TEXT_GENERATION = "text_generation" + EMBEDDINGS = "embeddings" + + # Image + IMAGE_GENERATION = "image_generation" + + # Video + VIDEO_INTELLIGENCE = "video_intelligence" + VIDEO_GENERATION = "video_generation" + + # Audio + AUDIO_INTELLIGENCE = "audio_intelligence" + + # Search + SEARCH = "search" + + +class Parameter(StrEnum): + """Universal parameters across all capabilities.""" + + TEMPERATURE = "temperature" + SEED = "seed" + MAX_TOKENS = "max_tokens" + + +__all__ = ["Capability", "Parameter", "Provider"] + diff --git a/src/celeste/py.typed b/src/celeste/py.typed new file mode 100644 index 00000000..e0183696 --- /dev/null +++ b/src/celeste/py.typed @@ -0,0 +1,2 @@ +# Marker file for PEP 561 - this package supports type checking + From d285e5217e772436b2d32060b180bee77fc10dd4 Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Tue, 4 Nov 2025 11:15:11 +0100 Subject: [PATCH 02/11] feat: add core IO types --- src/celeste/io.py | 88 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 src/celeste/io.py diff --git a/src/celeste/io.py b/src/celeste/io.py new file mode 100644 index 00000000..e357b601 --- /dev/null +++ b/src/celeste/io.py @@ -0,0 +1,88 @@ +"""Input and output types for generation operations.""" + +from typing import Any + +from pydantic import BaseModel, Field + + +class Input(BaseModel): + """Base class for capability-specific input types. + + Each capability defines its own Input subclass that specifies + the required fields for that capability's generate() method. + Input classes serve as the capability-level contract that all + providers must accept, enabling UI/CLI introspection and validation. + + Examples: + TextGenerationInput(prompt: str) + VideoIntelligenceInput(video: VideoArtifact, prompt: str) + EmbeddingsInput(text: str | list[str]) + """ + + pass + + +class FinishReason(BaseModel): + """Base class for capability-specific finish reasons. + + Each capability defines its own FinishReason subclass with + provider-reported fields (reason string, metadata, flags). + + Examples: + TextGenerationFinishReason(reason: str, stop_sequence: str | None) + ImageGenerationFinishReason(reason: str, safety_filtered: bool) + """ + + pass + + +class Usage(BaseModel): + """Base class for capability-specific usage metrics. + + Each capability defines its own Usage subclass with + provider-reported metrics (tokens, credits, frames, etc.). + + Examples: + TextGenerationUsage(input_tokens: int, output_tokens: int) + VideoGenerationUsage(frame_count: int, duration_seconds: float) + """ + + pass + + +class Output[Content](BaseModel): + """Base output class with generic content type. + + Attributes: + content: The generated content (type varies by capability). + usage: Capability-specific Usage subclass (e.g., TextGenerationUsage). + Providers MUST use concrete Usage types in practice. Empty base + Usage class serves as fallback when usage data unavailable. + metadata: Additional provider-specific metadata. + """ + + content: Content + usage: Usage = Field(default_factory=Usage) + metadata: dict[str, Any] = Field(default_factory=dict) + + +class Chunk[Content](BaseModel): + """Incremental chunk from streaming response with generic content type. + + Attributes: + content: Incremental content delta (type varies by capability). + finish_reason: Optional - typically only present in final chunk when + stream completes. + usage: Optional - providers typically report usage only in final chunk. + May be None for intermediate chunks. + metadata: Additional provider-specific metadata for this chunk. + """ + + content: Content + finish_reason: FinishReason | None = None + usage: Usage | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + +__all__ = ["Chunk", "FinishReason", "Input", "Output", "Usage"] + From 46ee7a04ca1bd8a2ef7ce88f067838dc0d811b63 Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Tue, 4 Nov 2025 11:20:55 +0100 Subject: [PATCH 03/11] feat: add core IO types --- src/celeste/io.py | 54 +++++------------------------------------------ 1 file changed, 5 insertions(+), 49 deletions(-) diff --git a/src/celeste/io.py b/src/celeste/io.py index e357b601..5c420176 100644 --- a/src/celeste/io.py +++ b/src/celeste/io.py @@ -6,60 +6,25 @@ class Input(BaseModel): - """Base class for capability-specific input types. - - Each capability defines its own Input subclass that specifies - the required fields for that capability's generate() method. - Input classes serve as the capability-level contract that all - providers must accept, enabling UI/CLI introspection and validation. - - Examples: - TextGenerationInput(prompt: str) - VideoIntelligenceInput(video: VideoArtifact, prompt: str) - EmbeddingsInput(text: str | list[str]) - """ + """Base class for capability-specific input types.""" pass class FinishReason(BaseModel): - """Base class for capability-specific finish reasons. - - Each capability defines its own FinishReason subclass with - provider-reported fields (reason string, metadata, flags). - - Examples: - TextGenerationFinishReason(reason: str, stop_sequence: str | None) - ImageGenerationFinishReason(reason: str, safety_filtered: bool) - """ + """Base class for capability-specific finish reasons (used in streaming chunks).""" pass class Usage(BaseModel): - """Base class for capability-specific usage metrics. - - Each capability defines its own Usage subclass with - provider-reported metrics (tokens, credits, frames, etc.). - - Examples: - TextGenerationUsage(input_tokens: int, output_tokens: int) - VideoGenerationUsage(frame_count: int, duration_seconds: float) - """ + """Base class for capability-specific usage metrics.""" pass class Output[Content](BaseModel): - """Base output class with generic content type. - - Attributes: - content: The generated content (type varies by capability). - usage: Capability-specific Usage subclass (e.g., TextGenerationUsage). - Providers MUST use concrete Usage types in practice. Empty base - Usage class serves as fallback when usage data unavailable. - metadata: Additional provider-specific metadata. - """ + """Base output class with generic content type.""" content: Content usage: Usage = Field(default_factory=Usage) @@ -67,16 +32,7 @@ class Output[Content](BaseModel): class Chunk[Content](BaseModel): - """Incremental chunk from streaming response with generic content type. - - Attributes: - content: Incremental content delta (type varies by capability). - finish_reason: Optional - typically only present in final chunk when - stream completes. - usage: Optional - providers typically report usage only in final chunk. - May be None for intermediate chunks. - metadata: Additional provider-specific metadata for this chunk. - """ + """Incremental chunk from streaming response with generic content type.""" content: Content finish_reason: FinishReason | None = None From 63b29445de0eadcdc77c164ea4b2cc684cf063e3 Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Tue, 4 Nov 2025 11:22:03 +0100 Subject: [PATCH 04/11] feat: add model registry --- src/celeste/models.py | 92 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 src/celeste/models.py diff --git a/src/celeste/models.py b/src/celeste/models.py new file mode 100644 index 00000000..27278c9b --- /dev/null +++ b/src/celeste/models.py @@ -0,0 +1,92 @@ +"""Models and model registry for Celeste.""" + +from pydantic import BaseModel, Field + +from celeste.constraints import Constraint +from celeste.core import Capability, Provider + + +class Model(BaseModel): + """Represents an AI model with its capabilities and metadata.""" + + id: str + provider: Provider + capabilities: set[Capability] = Field(default_factory=set) + display_name: str + parameter_constraints: dict[str, Constraint] = Field(default_factory=dict) + + @property + def supported_parameters(self) -> set[str]: + """Compute supported parameter names from parameter_constraints.""" + return set(self.parameter_constraints.keys()) + + +# Module-level registry mapping (model_id, provider) to model +_models: dict[tuple[str, Provider], Model] = {} + + +def register_models(models: Model | list[Model]) -> None: + """Register one or more models in the global registry. + + Args: + models: Single Model instance or list of Models to register. + Each model is indexed by (model_id, provider) tuple. + + Raises: + ValueError: If a model with the same (id, provider) is already registered. + """ + if isinstance(models, Model): + models = [models] + + for model in models: + key = (model.id, model.provider) + if key in _models: + msg = f"Model '{model.id}' for provider {model.provider.value} is already registered" + raise ValueError(msg) + _models[key] = model + + +def get_model(model_id: str, provider: Provider) -> Model | None: + """Get a registered model by ID and provider. + + Args: + model_id: The model identifier. + provider: The provider that owns the model. + + Returns: + Model instance if found, None otherwise. + """ + return _models.get((model_id, provider)) + + +def list_models( + provider: Provider | None = None, + capability: Capability | None = None, +) -> list[Model]: + """List all registered models, optionally filtered by provider and/or capability. + + Args: + provider: Optional provider filter. If provided, only models from this provider are returned. + capability: Optional capability filter. If provided, only models supporting this capability are returned. + + Returns: + List of Model instances matching the filters. + """ + filtered = list(_models.values()) + + if provider is not None: + filtered = [m for m in filtered if m.provider == provider] + + if capability is not None: + filtered = [m for m in filtered if capability in m.capabilities] + + return filtered + + +def clear() -> None: + """Clear all registered models from the registry.""" + _models.clear() + + +__all__ = ["Model", "clear", "get_model", "list_models", "register_models"] + From 9f974284eb81e2806972cdf68f2787d4ad1daf1b Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Tue, 4 Nov 2025 11:22:41 +0100 Subject: [PATCH 05/11] feat: add credentials management --- src/celeste/credentials.py | 107 +++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 src/celeste/credentials.py diff --git a/src/celeste/credentials.py b/src/celeste/credentials.py new file mode 100644 index 00000000..f212c7e8 --- /dev/null +++ b/src/celeste/credentials.py @@ -0,0 +1,107 @@ +"""Provider API credentials management for Celeste.""" + +from dotenv import find_dotenv +from pydantic import Field, SecretStr +from pydantic_settings import BaseSettings + +from celeste.core import Provider + +# Provider to credential field mapping +PROVIDER_CREDENTIAL_MAP = { + Provider.OPENAI: "openai_api_key", + Provider.ANTHROPIC: "anthropic_api_key", + Provider.GOOGLE: "google_api_key", + Provider.MISTRAL: "mistral_api_key", + Provider.HUGGINGFACE: "huggingface_token", + Provider.STABILITYAI: "stabilityai_api_key", + Provider.REPLICATE: "replicate_api_token", + Provider.COHERE: "cohere_api_key", + Provider.XAI: "xai_api_key", + Provider.LUMA: "luma_api_key", + Provider.TOPAZLABS: "topazlabs_api_key", + Provider.PERPLEXITY: "perplexity_api_key", +} + + +class Credentials(BaseSettings): + """API credentials for all supported providers.""" + + openai_api_key: SecretStr | None = Field(None, alias="OPENAI_API_KEY") + anthropic_api_key: SecretStr | None = Field(None, alias="ANTHROPIC_API_KEY") + google_api_key: SecretStr | None = Field(None, alias="GOOGLE_API_KEY") + mistral_api_key: SecretStr | None = Field(None, alias="MISTRAL_API_KEY") + huggingface_token: SecretStr | None = Field(None, alias="HUGGINGFACE_TOKEN") + stabilityai_api_key: SecretStr | None = Field(None, alias="STABILITYAI_API_KEY") + replicate_api_token: SecretStr | None = Field(None, alias="REPLICATE_API_TOKEN") + cohere_api_key: SecretStr | None = Field(None, alias="COHERE_API_KEY") + xai_api_key: SecretStr | None = Field(None, alias="XAI_API_KEY") + luma_api_key: SecretStr | None = Field(None, alias="LUMA_API_KEY") + topazlabs_api_key: SecretStr | None = Field(None, alias="TOPAZLABS_API_KEY") + perplexity_api_key: SecretStr | None = Field(None, alias="PERPLEXITY_API_KEY") + + model_config = { + "env_file": find_dotenv(), + "env_file_encoding": "utf-8", + "case_sensitive": False, + "extra": "ignore", # Ignore unknown env vars like context7_api_key + } + + def get_credentials( + self, provider: Provider, override_key: SecretStr | None = None + ) -> SecretStr: + """Get credentials for a specific provider with optional override. + + Args: + provider: The AI provider to get credentials for. + override_key: Optional SecretStr to use instead of environment variable. + + Returns: + SecretStr containing the API key for the provider. + + Raises: + ValueError: If provider requires credentials but none are configured, + or if provider is not supported (no credential mapping). + """ + if override_key: + return override_key + + if not self.has_credential(provider): + raise ValueError(f"Provider {provider} has no credentials configured.") + + credential: SecretStr = getattr(self, PROVIDER_CREDENTIAL_MAP[provider]) + return credential + + def list_available_providers(self) -> list[Provider]: + """List all providers that have credentials configured. + + Returns: + List of Provider enums that have credentials configured via environment variables. + """ + return [ + provider + for provider in PROVIDER_CREDENTIAL_MAP + if self.has_credential(provider) + ] + + def has_credential(self, provider: Provider) -> bool: + """Check if a specific provider has credentials configured. + + Args: + provider: The AI provider to check. + + Returns: + True if provider has credentials configured, False if credentials not set. + + Raises: + ValueError: If provider has no credential mapping. + """ + credential_field = PROVIDER_CREDENTIAL_MAP.get(provider) + if not credential_field: + raise ValueError(f"Provider {provider} has no credential mapping") + return getattr(self, credential_field, None) is not None + + +credentials = Credentials() # type: ignore[call-arg] + +__all__ = ["Credentials", "credentials"] + From 1f529447f490c2630ce77fc149e740bab8f4ca0c Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Tue, 4 Nov 2025 12:00:50 +0100 Subject: [PATCH 06/11] feat: add parameters and HTTP client modules --- src/celeste/__init__.py | 126 +++++++++++++++++++++++ src/celeste/client.py | 151 +++++++++++++++++++++++++++ src/celeste/core.py | 2 +- src/celeste/credentials.py | 6 +- src/celeste/http.py | 203 +++++++++++++++++++++++++++++++++++++ src/celeste/parameters.py | 52 ++++++++++ src/celeste/streaming.py | 114 +++++++++++++++++++++ 7 files changed, 651 insertions(+), 3 deletions(-) create mode 100644 src/celeste/__init__.py create mode 100644 src/celeste/client.py create mode 100644 src/celeste/http.py create mode 100644 src/celeste/parameters.py create mode 100644 src/celeste/streaming.py diff --git a/src/celeste/__init__.py b/src/celeste/__init__.py new file mode 100644 index 00000000..1cd29491 --- /dev/null +++ b/src/celeste/__init__.py @@ -0,0 +1,126 @@ +import importlib.metadata +import logging + +from pydantic import SecretStr + +from celeste.client import Client, get_client_class, register_client +from celeste.core import Capability, Parameter, Provider +from celeste.credentials import credentials +from celeste.http import HTTPClient +from celeste.io import Input, Output, Usage +from celeste.models import Model, get_model, list_models, register_models +from celeste.parameters import Parameters + +logger = logging.getLogger(__name__) + + +def _resolve_model( + capability: Capability, + provider: Provider | None, + model: Model | str | None, +) -> Model: + """Resolve model parameter to Model object (auto-select if None, lookup if string).""" + if model is None: + # Auto-select first available model + models = list_models(provider=provider, capability=capability) + if not models: + msg = f"No model found for {capability}" + raise ValueError(msg) + return models[0] + + if isinstance(model, str): + # String ID requires provider + if not provider: + msg = "provider required when model is a string ID" + raise ValueError(msg) + found = get_model(model, provider) + if not found: + msg = f"Model '{model}' not found for provider {provider}" + raise ValueError(msg) + return found + + return model + + +def create_client( + capability: Capability, + provider: Provider | None = None, + model: Model | str | None = None, + api_key: SecretStr | None = None, +) -> Client: + """Create an async client for the specified AI capability. + + Args: + capability: The AI capability to use (e.g., TEXT_GENERATION). + provider: Optional provider. Required if model is a string ID. + model: Model object, string model ID, or None for auto-selection. + api_key: Optional SecretStr override. If not specified, loaded from environment. + + Returns: + Configured client instance ready for generation operations. + + Raises: + ValueError: If no model found or resolution fails. + NotImplementedError: If no client registered for capability/provider. + """ + # Resolve model + resolved_model = _resolve_model(capability, provider, model) + + # Get client class and credentials + client_class = get_client_class(capability, resolved_model.provider) + resolved_key = credentials.get_credentials( + resolved_model.provider, override_key=api_key + ) + + # Create and return client + return client_class( + model=resolved_model, + provider=resolved_model.provider, + capability=capability, + api_key=resolved_key, + ) + + +def _load_from_entry_points() -> None: + """Load models and clients from installed packages via entry points.""" + entry_points = importlib.metadata.entry_points(group="celeste.packages") + + for ep in entry_points: + try: + register_func = ep.load() + # The function should register models and clients when called + register_func() + except (ImportError, AttributeError, ValueError) as e: + # Import-time errors should be visible even without logging configuration + import sys + + print( + f"Warning: Failed to load package '{ep.name}': {e}", + file=sys.stderr, + ) + logger.debug(f"Full traceback for package '{ep.name}'", exc_info=True) + + +# Load from entry points on import +_load_from_entry_points() + +# Exports +__all__ = [ + "Capability", + "Client", + "HTTPClient", + "Input", + "Model", + "Output", + "Parameter", + "Parameters", + "Provider", + "Usage", + "create_client", + "get_client_class", + "get_model", + "list_models", + "register_client", + "register_models", +] + diff --git a/src/celeste/client.py b/src/celeste/client.py new file mode 100644 index 00000000..dbe56690 --- /dev/null +++ b/src/celeste/client.py @@ -0,0 +1,151 @@ +"""Base client and client registry for AI capabilities.""" + +from abc import ABC, abstractmethod +from typing import Any, Unpack + +from pydantic import BaseModel, ConfigDict, Field, SecretStr + +from celeste.core import Capability, Provider +from celeste.http import HTTPClient, get_http_client +from celeste.io import Input, Output, Usage +from celeste.models import Model +from celeste.parameters import ParameterMapper, Parameters +from celeste.streaming import Stream + + +class Client[In: Input, Out: Output](ABC, BaseModel): + """Base class for all capability-specific clients.""" + + model_config = ConfigDict(from_attributes=True) + + model: Model + provider: Provider + capability: Capability + api_key: SecretStr = Field(exclude=True) + + def model_post_init(self, __context: object) -> None: + """Validate capability compatibility.""" + if self.capability not in self.model.capabilities: + raise ValueError( + f"Model '{self.model.id}' does not support capability {self.capability.value}" + ) + + @property + def http_client(self) -> HTTPClient: + """Shared HTTP client with connection pooling for this provider.""" + return get_http_client(self.provider, self.capability) + + @classmethod + @abstractmethod + def parameter_mappers(cls) -> list[ParameterMapper]: + """Provider-specific parameter mappers.""" + ... + + @abstractmethod + def _init_request(self, inputs: In) -> dict[str, Any]: + """Initialize provider-specific base request structure.""" + pass + + @abstractmethod + def _parse_usage(self, response_data: dict[str, Any]) -> Usage: + """Parse usage information from provider response.""" + pass + + @abstractmethod + def _parse_content( + self, response_data: dict[str, Any], **parameters: Unpack[Parameters] + ) -> object: + """Parse content from provider response.""" + pass + + def _transform_output( + self, content: object, **parameters: Unpack[Parameters] + ) -> object: + """Transform content using parameter mapper output transformations.""" + for mapper in self.parameter_mappers(): + value = parameters.get(mapper.name) + if value is not None: + content = mapper.parse_output(content, value) + return content + + def _build_request( + self, inputs: In, **parameters: Unpack[Parameters] + ) -> dict[str, Any]: + """Build complete request by combining base request with parameters.""" + request = self._init_request(inputs) + + # Apply parameter mappers from registry + for mapper in self.parameter_mappers(): + value = parameters.get(mapper.name) + request = mapper.map(request, value, self.model) + + return request + + def stream(self, *args: Any, **parameters: Unpack[Parameters]) -> Stream[Out]: # noqa: ANN401 + """Stream content - signature varies by capability. + + Args: + *args: Capability-specific positional arguments (same as generate). + **parameters: Capability-specific keyword arguments (same as generate). + + Returns: + Stream yielding chunks and providing final Output. + + Raises: + NotImplementedError: If capability doesn't support streaming. + """ + msg = f"{self.capability.value} or {self.provider.value} does not support streaming" + raise NotImplementedError(msg) + + @abstractmethod + async def generate(self, *args: Any, **parameters: Unpack[Parameters]) -> Out: # noqa: ANN401 + """Generate content - signature varies by capability. + + Args: + *args: Capability-specific positional arguments (prompt, text, image_url, etc.). + **parameters: Capability-specific keyword arguments (temperature, max_tokens, etc.). + + Returns: + Output of the parameterized type (e.g., TextGenerationOutput). + """ + pass + + +_clients: dict[tuple[Capability, Provider], type[Client]] = {} + + +def register_client( + capability: Capability, provider: Provider, client_class: type[Client] +) -> None: + """Register a provider-specific client class for a capability. + + Args: + capability: The capability this client implements. + provider: The provider this client uses. + client_class: The client class to register. + """ + _clients[(capability, provider)] = client_class + + +def get_client_class(capability: Capability, provider: Provider) -> type[Client]: + """Get the registered client class for a capability and provider. + + Args: + capability: The capability to get a client for. + provider: The provider to use. + + Returns: + The registered client class. + + Raises: + NotImplementedError: If no client is registered for this capability/provider. + """ + if (capability, provider) not in _clients: + raise NotImplementedError( + f"No client registered for {capability.value} with provider {provider.value}" + ) + return _clients[(capability, provider)] + + +__all__ = ["Client", "get_client_class", "register_client"] + diff --git a/src/celeste/core.py b/src/celeste/core.py index 91b46d99..1dc25efc 100644 --- a/src/celeste/core.py +++ b/src/celeste/core.py @@ -42,7 +42,7 @@ class Capability(StrEnum): class Parameter(StrEnum): - """Universal parameters across all capabilities.""" + """Universal parameters across most capabilities.""" TEMPERATURE = "temperature" SEED = "seed" diff --git a/src/celeste/credentials.py b/src/celeste/credentials.py index f212c7e8..049723ff 100644 --- a/src/celeste/credentials.py +++ b/src/celeste/credentials.py @@ -66,7 +66,8 @@ def get_credentials( return override_key if not self.has_credential(provider): - raise ValueError(f"Provider {provider} has no credentials configured.") + msg = f"Provider {provider} has no credentials configured." + raise ValueError(msg) credential: SecretStr = getattr(self, PROVIDER_CREDENTIAL_MAP[provider]) return credential @@ -97,7 +98,8 @@ def has_credential(self, provider: Provider) -> bool: """ credential_field = PROVIDER_CREDENTIAL_MAP.get(provider) if not credential_field: - raise ValueError(f"Provider {provider} has no credential mapping") + msg = f"Provider {provider} has no credential mapping" + raise ValueError(msg) return getattr(self, credential_field, None) is not None diff --git a/src/celeste/http.py b/src/celeste/http.py new file mode 100644 index 00000000..a88ff2d5 --- /dev/null +++ b/src/celeste/http.py @@ -0,0 +1,203 @@ +"""HTTP client with persistent connection pooling for AI provider APIs.""" + +import json +from collections.abc import AsyncIterator +from typing import Any + +import httpx +from httpx_sse import aconnect_sse + +from celeste.core import Capability, Provider + +MAX_CONNECTIONS = 20 +MAX_KEEPALIVE_CONNECTIONS = 10 +DEFAULT_TIMEOUT = 60.0 + + +class HTTPClient: + """Async HTTP client with persistent connection pooling.""" + + def __init__( + self, + max_connections: int = MAX_CONNECTIONS, + max_keepalive_connections: int = MAX_KEEPALIVE_CONNECTIONS, + ) -> None: + """Initialize HTTP client with connection pool limits. + + Args: + max_connections: Maximum total connections in pool. + max_keepalive_connections: Maximum idle keepalive connections. + """ + self._client: httpx.AsyncClient | None = None + self._max_connections = max_connections + self._max_keepalive_connections = max_keepalive_connections + + async def _get_client(self) -> httpx.AsyncClient: + """Get or create httpx.AsyncClient with connection pooling.""" + if self._client is None: + limits = httpx.Limits( + max_connections=self._max_connections, + max_keepalive_connections=self._max_keepalive_connections, + ) + self._client = httpx.AsyncClient(limits=limits) # nosec B113 + return self._client + + async def post( + self, + url: str, + headers: dict[str, str], + json_body: dict[str, Any], + timeout: float = DEFAULT_TIMEOUT, + ) -> httpx.Response: + """Make POST request with connection pooling. + + Args: + url: Full URL to POST to. + headers: HTTP headers including authentication. + json_body: JSON request body. + timeout: Request timeout in seconds. + + Returns: + HTTP response from the server. + + Raises: + httpx.HTTPError: On network or timeout errors. + """ + client = await self._get_client() + return await client.post( + url, + headers=headers, + json=json_body, + timeout=timeout, + ) + + async def get( + self, + url: str, + headers: dict[str, str] | None = None, + timeout: float = DEFAULT_TIMEOUT, + follow_redirects: bool = True, + ) -> httpx.Response: + """Make GET request with connection pooling. + + Args: + url: Full URL to GET. + headers: HTTP headers including authentication (optional). + timeout: Request timeout in seconds. + follow_redirects: Whether to follow HTTP redirects (default: True). + + Returns: + HTTP response from the server. + + Raises: + httpx.HTTPError: On network or timeout errors. + """ + client = await self._get_client() + return await client.get( + url, + headers=headers or {}, + timeout=timeout, + follow_redirects=follow_redirects, + ) + + async def stream_post( + self, + url: str, + headers: dict[str, str], + json_body: dict[str, Any], + timeout: float = DEFAULT_TIMEOUT, + ) -> AsyncIterator[dict[str, Any]]: + """Stream POST request using Server-Sent Events. + + 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 events from SSE stream. + """ + client = await self._get_client() + + async with aconnect_sse( + client, + "POST", + url, + json=json_body, + headers=headers, + timeout=timeout, + ) as event_source: + async for sse in event_source.aiter_sse(): + try: + yield json.loads(sse.data) + except json.JSONDecodeError: + continue # Skip non-JSON control messages (provider-agnostic) + + async def aclose(self) -> None: + """Close HTTP client and cleanup all connections.""" + if self._client is not None: + await self._client.aclose() + self._client = None + + async def __aenter__(self) -> "HTTPClient": + """Enter async context manager.""" + return self + + async def __aexit__(self, *args: Any) -> None: # noqa: ANN401 + """Exit async context manager and cleanup connections.""" + await self.aclose() + + +# Module-level registry of shared HTTPClient instances +_http_clients: dict[tuple[Provider, Capability], HTTPClient] = {} + + +def get_http_client(provider: Provider, capability: Capability) -> HTTPClient: + """Get or create shared HTTP client for provider and capability combination. + + Args: + provider: The AI provider. + capability: The capability being used. + + Returns: + Shared HTTPClient instance for this provider and capability. + """ + key = (provider, capability) + + if key not in _http_clients: + _http_clients[key] = HTTPClient() + + return _http_clients[key] + + +async def close_all_http_clients() -> None: + """Close all HTTP clients gracefully and clear registry.""" + import logging + + logger = logging.getLogger(__name__) + + for key, client in list(_http_clients.items()): + try: + await client.aclose() + except Exception as e: + logger.warning(f"Failed to close HTTP client for {key}: {e}") + + _http_clients.clear() + + +def clear_http_clients() -> None: + """Clear HTTP client registry without closing connections.""" + _http_clients.clear() + + +__all__ = [ + "DEFAULT_TIMEOUT", + "MAX_CONNECTIONS", + "MAX_KEEPALIVE_CONNECTIONS", + "HTTPClient", + "clear_http_clients", + "close_all_http_clients", + "get_http_client", +] + diff --git a/src/celeste/parameters.py b/src/celeste/parameters.py new file mode 100644 index 00000000..5eec14ae --- /dev/null +++ b/src/celeste/parameters.py @@ -0,0 +1,52 @@ +"""Parameter system for Celeste.""" + +from abc import ABC, abstractmethod +from enum import StrEnum +from typing import Any, TypedDict + +from celeste.models import Model + + +class Parameters(TypedDict, total=False): + """Base parameters for all capabilities.""" + + +class ParameterMapper(ABC): + """Base class for provider-specific parameter transformation.""" + + name: StrEnum + """Parameter name matching capability TypedDict key. Must be StrEnum for type safety.""" + + @abstractmethod + def map(self, request: dict[str, Any], value: Any, model: Model) -> dict[str, Any]: # noqa: ANN401 + """Transform parameter value into provider's request structure. + + Args: + request: Provider request dict. + value: Parameter value. + model: Model instance containing parameter_constraints for validation. + + Returns: + Updated request dict. + """ + ... + + def parse_output(self, content: object, value: object | None) -> object: + """Optionally transform parsed content based on parameter value (default: return unchanged).""" + return content + + def _validate_value(self, value: Any, model: Model) -> Any: # noqa: ANN401 + """Validate parameter value using model constraint, raising ValueError if no constraint exists.""" + if value is None: + return None + + constraint = model.parameter_constraints.get(self.name) + if constraint is None: + msg = f"Parameter {self.name.value} is not supported by model {model.id}" + raise ValueError(msg) + + return constraint(value) + + +__all__ = ["ParameterMapper", "Parameters"] + diff --git a/src/celeste/streaming.py b/src/celeste/streaming.py new file mode 100644 index 00000000..b486b43a --- /dev/null +++ b/src/celeste/streaming.py @@ -0,0 +1,114 @@ +"""Streaming support for Celeste.""" + +from abc import ABC, abstractmethod +from collections.abc import AsyncIterator +from typing import Any, Self + +from celeste.io import Chunk, Output + + +class Stream[Out: Output](ABC): + """Async iterator wrapper providing final Output access after stream exhaustion.""" + + def __init__( + self, + sse_iterator: AsyncIterator[dict[str, Any]], + ) -> None: + """Initialize stream with SSE iterator.""" + self._sse_iterator = sse_iterator + self._chunks: list[Chunk] = [] + self._closed = False + self._output: Out | None = None + + @abstractmethod + def _parse_chunk(self, event: dict[str, Any]) -> Chunk | None: + """Parse SSE event into Chunk (returns None to filter lifecycle events).""" + ... + + @abstractmethod + def _parse_output(self, chunks: list[Chunk]) -> Out: + """Parse final Output from accumulated chunks.""" + ... + + def __repr__(self) -> str: + """Developer-friendly representation showing stream state.""" + if self._output: + state = "done" + elif self._closed: + state = "closed" + elif self._chunks: + state = "streaming" + else: + state = "idle" + + chunks = f", {len(self._chunks)} chunks" if self._chunks else "" + return f"<{self.__class__.__name__}: {state}{chunks}>" + + # AsyncIterator protocol + def __aiter__(self) -> Self: + """Return self as async iterator.""" + return self + + async def __anext__(self) -> Chunk: + """Yield next chunk from stream.""" + if self._closed: + raise StopAsyncIteration + + try: + async for event in self._sse_iterator: + chunk = self._parse_chunk(event) + if chunk is not None: + self._chunks.append(chunk) + return chunk + + # Stream exhausted - validate and parse final output + if not self._chunks: + msg = "Stream completed but no chunks were produced" + raise RuntimeError(msg) + + self._output = self._parse_output(self._chunks) + except Exception: + await self.aclose() + raise + + # Only reached on successful exhaustion + await self.aclose() + raise StopAsyncIteration + + # AsyncContextManager protocol + async def __aenter__(self) -> Self: + """Enter context - return self for iteration.""" + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: Any, # noqa: ANN401 + ) -> bool: + """Exit context - ensure cleanup even on exception.""" + await self.aclose() + return False # Propagate exceptions + + @property + def output(self) -> Out: + """Access final Output after stream exhaustion (raises RuntimeError if not ready).""" + if self._output is None: + msg = "Stream not exhausted. Consume all chunks before accessing .output" + raise RuntimeError(msg) + return self._output + + async def aclose(self) -> None: + """Explicitly close stream and cleanup resources.""" + if self._closed: + return + + self._closed = True + + # Close SSE iterator (httpx-sse connection) + if hasattr(self._sse_iterator, "aclose"): + await self._sse_iterator.aclose() + + +__all__ = ["Stream"] + From 3253652054a91aa979c72237245f9f4df9e27f71 Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Tue, 4 Nov 2025 12:15:53 +0100 Subject: [PATCH 07/11] chore: setup repository infrastructure --- .gitignore | 159 ++++++++++++++++++++++++++++++++++++++++ .pre-commit-config.yaml | 61 +++++++++++++++ Makefile | 79 ++++++++++++++++++++ README.md | 134 +++++++++++++++++++++++++++++++++ logo.svg | 11 +++ pyproject.toml | 150 +++++++++++++++++++++++++++++++++++++ 6 files changed, 594 insertions(+) create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 Makefile create mode 100644 README.md create mode 100644 logo.svg create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..730293e9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,159 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +Pipfile.lock + +# poetry +poetry.lock + +# pdm +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582 +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Backup files +*.bak + +# UV +.uv/ +uv.lock + +# Ruff +.ruff_cache/ + +# Security reports +bandit-report.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..31d00b9b --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,61 @@ +# See https://pre-commit.com for more information +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: no-commit-to-branch + name: "๐Ÿšซ Prevent commits to protected branches" + args: [--branch, main, --branch, master, --branch, develop] + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - id: check-toml + - id: check-merge-conflict + - id: check-json + - id: debug-statements # Check for print/pdb statements + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.3 + hooks: + - id: ruff-check + args: [--fix] + files: ^(src/celeste|tests)/ + name: "๐Ÿ Lint with Ruff" + - id: ruff-format + files: ^(src/celeste|tests)/ + name: "๐Ÿ Format with Ruff" + + - repo: local + hooks: + - id: mypy + name: "๐Ÿ Type check with mypy" + entry: uv run mypy -p celeste + language: system + types: [python] + pass_filenames: false + - id: mypy-tests + name: "๐Ÿ Type check tests with mypy" + entry: uv run mypy tests/ + language: system + types: [python] + pass_filenames: false + + - repo: https://github.com/PyCQA/bandit + rev: 1.8.6 + hooks: + - id: bandit + name: "๐Ÿ”’ Security check with Bandit" + args: ["-c", "pyproject.toml", "-r", "src/"] + additional_dependencies: ["bandit[toml]"] + + - repo: local + hooks: + - id: pytest + name: "๐Ÿงช Run tests with coverage" + entry: uv run pytest tests/ --cov=celeste --cov-report=term-missing + language: system + types: [python] + pass_filenames: false + always_run: true + stages: [pre-push] # Run on push, not commit (keeps commits fast) diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..6e8fbd68 --- /dev/null +++ b/Makefile @@ -0,0 +1,79 @@ +.PHONY: help sync lint lint-fix format typecheck test security ci clean + +# Default target +help: + @echo "Available commands:" + @echo " make sync - Update and sync all dependencies with all extras" + @echo " make lint - Run Ruff linting" + @echo " make format - Apply Ruff formatting" + @echo " make typecheck - Run mypy type checking" + @echo " make test - Run pytest with coverage" + @echo " make security - Run Bandit security scan" + @echo " make ci - Run full CI/CD pipeline" + @echo " make clean - Clean cache directories" + +# Complete dependency sync - update lock, sync everything +sync: + @echo "๐Ÿ”„ Updating dependencies..." + @uv sync --all-packages --all-extras --upgrade + @echo "โœ… All dependencies synced and updated" + +# Linting +lint: + uv run ruff check src/celeste tests/ + +# Linting with auto-fix +lint-fix: + uv run ruff check --fix src/celeste tests/ + +# Formatting +format: + uv run ruff format src/celeste tests/ + +# Type checking (fail fast on any error) +typecheck: + @uv run mypy -p celeste && uv run mypy tests/ + +# Testing +test: + uv run pytest tests/ --cov=celeste --cov-report=term-missing --cov-fail-under=90 + +# Security scanning (config reads from pyproject.toml) +security: + uv run bandit -c pyproject.toml -r src/ -f screen + +# Full CI/CD pipeline - what GitHub Actions will run +ci: + @echo "๐Ÿ” Running Full CI/CD Pipeline..." + @echo "=================================" + @echo "1๏ธโƒฃ Ruff Linting (with auto-fix)..." + @$(MAKE) lint-fix || (echo "โŒ Linting failed" && exit 1) + @echo "โœ… Linting passed" + @echo "" + @echo "2๏ธโƒฃ Ruff Formatting..." + @$(MAKE) format || (echo "โŒ Formatting failed" && exit 1) + @echo "โœ… Formatting applied" + @echo "" + @echo "3๏ธโƒฃ MyPy Type Checking (parallel)..." + @$(MAKE) typecheck || (echo "โŒ Type checking failed" && exit 1) + @echo "โœ… Type checking passed" + @echo "" + @echo "4๏ธโƒฃ Bandit Security Scan..." + @$(MAKE) security || (echo "โŒ Security scan failed" && exit 1) + @echo "โœ… Security scan passed" + @echo "" + @echo "5๏ธโƒฃ Running Tests with Coverage..." + @$(MAKE) test || (echo "โŒ Tests failed" && exit 1) + @echo "" + @echo "=================================" + @echo "๐ŸŽ‰ All CI/CD checks passed! Ready to commit." + +# Clean cache directories +clean: + rm -rf .pytest_cache + rm -rf .mypy_cache + rm -rf .ruff_cache + rm -rf __pycache__ + find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true + find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true + find . -type f -name "*.pyc" -delete diff --git a/README.md b/README.md new file mode 100644 index 00000000..eef1cd9b --- /dev/null +++ b/README.md @@ -0,0 +1,134 @@ +
+ +# Celeste AI + +Celeste Logo + +**The primitive layer for multi-modal AI** + +All capabilities. All providers. One interface. + +Primitives, not frameworks. + +[![Python](https://img.shields.io/badge/Python-3.12+-blue?style=flat-square)](https://www.python.org/) +[![License](https://img.shields.io/badge/License-Apache_2.0-blue?style=flat-square)](LICENSE) +[![PyPI](https://img.shields.io/badge/PyPI-celeste--ai-orange?style=flat-square)](https://pypi.org/project/celeste-ai/) + +[Quick Start](#-quick-start) โ€ข [Request Provider](https://github.com/withceleste/celeste-python/issues/new) + +
+ +--- + +## ๐Ÿš€ Quick Start + +```python +from celeste import create_client, Capability, Provider + +# Create client +client = create_client( + capability=Capability.TEXT_GENERATION, + provider=Provider.ANTHROPIC, + api_key="your-api-key", # Or loads automatically from environment +) + +# Generate +response = await client.generate(prompt="Explain quantum computing") +print(response.content) +``` + +**Install:** +```bash +uv add "celeste-ai[text-generation]" # Text only +uv add "celeste-ai[image-generation]" # Image generation +uv add "celeste-ai[all]" # Everything +``` + +--- + +## ๐ŸŽจ Multi-Modal Example + +```python +# Same API, different modalities +text_client = create_client(Capability.TEXT_GENERATION, Provider.ANTHROPIC) +image_client = create_client(Capability.IMAGE_GENERATION, Provider.OPENAI) +video_client = create_client(Capability.VIDEO_GENERATION, Provider.GOOGLE) + +text = await text_client.generate(prompt="Write a haiku about AI") +image = await image_client.generate(prompt="A sunset over mountains") +video = await video_client.generate(prompt="Waves crashing on a beach") +``` + +No special cases. No separate libraries. **One consistent interface.** + +--- + + +
+ +Google +Anthropic +OpenAI +Mistral +Cohere +xAI +DeepSeek +Groq +Perplexity +Ollama +Hugging Face +Replicate +Stability AI +Runway +ElevenLabs + +**and many more** + +**Missing a provider?** [Request it](https://github.com/withceleste/celeste-python/issues/new) โ€“ โšก **we ship fast**. + +
+ +--- + +## ๐Ÿ”ง Type-Safe by Design + +```python +# Full IDE autocomplete +response = await client.generate( + prompt="Explain AI", + temperature=0.7, # โœ… Validated (0.0-2.0) + max_tokens=100, # โœ… Validated (int) +) + +# Typed response +print(response.content) # str (IDE knows the type) +print(response.usage.input_tokens) # int +print(response.metadata["model"]) # str +``` + +Catch errors **before** production. + +--- + +## ๐Ÿค Contributing + +We welcome contributions! See [CONTRIBUTING.md](CONTRIBUTING.md). + +**Request a provider:** [GitHub Issues](https://github.com/withceleste/celeste-python/issues/new) +**Report bugs:** [GitHub Issues](https://github.com/withceleste/celeste-python/issues) + +--- + +## ๐Ÿ“„ License + +Apache 2.0 license โ€“ see [LICENSE](LICENSE) for details. + +--- + +
+ +**[Get Started](https://withceleste.ai/docs/quickstart)** โ€ข **[Documentation](https://withceleste.ai/docs)** โ€ข **[GitHub](https://github.com/withceleste/celeste-python)** + +Made with โค๏ธ by developers tired of framework lock-in + +
diff --git a/logo.svg b/logo.svg new file mode 100644 index 00000000..2703d56b --- /dev/null +++ b/logo.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..8499ac44 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,150 @@ +[project] +name = "celeste-ai" +version = "0.0.2" +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" +license = {text = "Apache-2.0"} +requires-python = ">=3.12" +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Operating System :: OS Independent", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Typing :: Typed", +] +keywords = ["ai", "multimodal", "sdk", "openai", "anthropic", "claude", "gemini", "text-generation", "image-generation", "video-generation", "embeddings"] +dependencies = [ + "pydantic>=2.0", + "pydantic-settings>=2.0", + "httpx>=0.27.0", + "httpx-sse>=0.4.0", + "python-dotenv>=1.0.0", +] + +[project.urls] +Homepage = "https://withceleste.ai" +Documentation = "https://withceleste.ai/docs" +Repository = "https://github.com/withceleste/celeste-python" +Issues = "https://github.com/withceleste/celeste-python/issues" + +[dependency-groups] +dev = [ + "pytest>=8.0", + "pytest-cov>=7.0", + "pytest-randomly>=4.0", + "pytest-asyncio>=1.2.0", + "ruff>=0.8.0", + "mypy>=1.13.0", + "types-requests>=2.31.0", + "bandit[toml]>=1.7.5", + "pre-commit>=3.5.0", +] + +[project.entry-points."celeste.packages"] +# Example entry points for packages to register their models and clients: +# text_generation = "text_generation:register_models" +# image_generation = "image_generation:register_models" + +[tool.uv.workspace] +members = ["packages/*"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/celeste"] + +[tool.pytest.ini_options] +minversion = "8.0" +testpaths = ["tests"] +addopts = "-ra --strict-markers --strict-config" +asyncio_mode = "auto" +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "smoke: quick checks for critical paths", +] + +[tool.coverage.run] +branch = true +# dynamic_context handled by pytest-cov's --cov-context option + +[tool.coverage.report] +show_missing = true +fail_under = 90 + +[tool.ruff] +# Same as Black's default +line-length = 88 +target-version = "py312" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "F", # Pyflakes (unused imports, undefined names) + "I", # isort (import sorting) + "UP", # pyupgrade (upgrade syntax for newer Python) + "B", # flake8-bugbear (catches common bugs) + "SIM", # flake8-simplify (code simplification) + "RUF", # Ruff-specific rules + "ANN", # flake8-annotations (enforce type annotations) + "A", # flake8-builtins (prevent shadowing builtins) +] +ignore = [ + "E501", # Line too long - trust formatter's judgment + "SIM108", # Use ternary operator instead of if-else-block (sometimes less readable) +] +fixable = ["ALL"] +unfixable = [] + +[tool.ruff.format] +# Match Black's style +quote-style = "double" +indent-style = "space" +docstring-code-format = true + +[tool.ruff.lint.isort] +known-first-party = ["celeste"] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["D"] # No docstrings required in tests + +[tool.mypy] +python_version = "3.12" +mypy_path = "src" +# Strict type checking for production code +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true # Require type hints +disallow_any_unimported = true +disallow_incomplete_defs = true +check_untyped_defs = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +strict_equality = true + +[[tool.mypy.overrides]] +module = "tests.*" +disallow_untyped_defs = false # Relax for tests +disallow_incomplete_defs = false + +[[tool.mypy.overrides]] +module = "httpx" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "httpx_sse" +ignore_missing_imports = true + +[tool.bandit] +exclude_dirs = [".venv", "__pycache__"] +skips = ["B101"] # Skip B101 (assert_used) since we use pytest + +[tool.bandit.assert_used] +skips = ["*/test_*.py", "*_test.py"] # Allow assertions in test files From 438cad55848d0224bd2b777c8191d5817ac4450a Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Tue, 4 Nov 2025 12:25:49 +0100 Subject: [PATCH 08/11] fix: apply pre-commit hook fixes --- src/celeste/__init__.py | 1 - src/celeste/client.py | 1 - src/celeste/constraints.py | 1 - src/celeste/core.py | 1 - src/celeste/credentials.py | 1 - src/celeste/http.py | 1 - src/celeste/io.py | 1 - src/celeste/models.py | 1 - src/celeste/parameters.py | 1 - src/celeste/py.typed | 1 - src/celeste/streaming.py | 1 - 11 files changed, 11 deletions(-) diff --git a/src/celeste/__init__.py b/src/celeste/__init__.py index 1cd29491..370ec669 100644 --- a/src/celeste/__init__.py +++ b/src/celeste/__init__.py @@ -123,4 +123,3 @@ def _load_from_entry_points() -> None: "register_client", "register_models", ] - diff --git a/src/celeste/client.py b/src/celeste/client.py index dbe56690..704a1b9c 100644 --- a/src/celeste/client.py +++ b/src/celeste/client.py @@ -148,4 +148,3 @@ def get_client_class(capability: Capability, provider: Provider) -> type[Client] __all__ = ["Client", "get_client_class", "register_client"] - diff --git a/src/celeste/constraints.py b/src/celeste/constraints.py index 3dc159d0..94579c54 100644 --- a/src/celeste/constraints.py +++ b/src/celeste/constraints.py @@ -175,4 +175,3 @@ def __call__(self, value: type[BaseModel]) -> type[BaseModel]: "Schema", "Str", ] - diff --git a/src/celeste/core.py b/src/celeste/core.py index 1dc25efc..91df5caf 100644 --- a/src/celeste/core.py +++ b/src/celeste/core.py @@ -50,4 +50,3 @@ class Parameter(StrEnum): __all__ = ["Capability", "Parameter", "Provider"] - diff --git a/src/celeste/credentials.py b/src/celeste/credentials.py index 049723ff..f462bc81 100644 --- a/src/celeste/credentials.py +++ b/src/celeste/credentials.py @@ -106,4 +106,3 @@ def has_credential(self, provider: Provider) -> bool: credentials = Credentials() # type: ignore[call-arg] __all__ = ["Credentials", "credentials"] - diff --git a/src/celeste/http.py b/src/celeste/http.py index a88ff2d5..3c839065 100644 --- a/src/celeste/http.py +++ b/src/celeste/http.py @@ -200,4 +200,3 @@ def clear_http_clients() -> None: "close_all_http_clients", "get_http_client", ] - diff --git a/src/celeste/io.py b/src/celeste/io.py index 5c420176..12974f3d 100644 --- a/src/celeste/io.py +++ b/src/celeste/io.py @@ -41,4 +41,3 @@ class Chunk[Content](BaseModel): __all__ = ["Chunk", "FinishReason", "Input", "Output", "Usage"] - diff --git a/src/celeste/models.py b/src/celeste/models.py index 27278c9b..49f48f03 100644 --- a/src/celeste/models.py +++ b/src/celeste/models.py @@ -89,4 +89,3 @@ def clear() -> None: __all__ = ["Model", "clear", "get_model", "list_models", "register_models"] - diff --git a/src/celeste/parameters.py b/src/celeste/parameters.py index 5eec14ae..47c62e35 100644 --- a/src/celeste/parameters.py +++ b/src/celeste/parameters.py @@ -49,4 +49,3 @@ def _validate_value(self, value: Any, model: Model) -> Any: # noqa: ANN401 __all__ = ["ParameterMapper", "Parameters"] - diff --git a/src/celeste/py.typed b/src/celeste/py.typed index e0183696..321d0ae1 100644 --- a/src/celeste/py.typed +++ b/src/celeste/py.typed @@ -1,2 +1 @@ # Marker file for PEP 561 - this package supports type checking - diff --git a/src/celeste/streaming.py b/src/celeste/streaming.py index b486b43a..63ed1552 100644 --- a/src/celeste/streaming.py +++ b/src/celeste/streaming.py @@ -111,4 +111,3 @@ async def aclose(self) -> None: __all__ = ["Stream"] - From 1604cd552c209dd987516732afffe9a0610dbd7e Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Tue, 4 Nov 2025 14:31:32 +0100 Subject: [PATCH 09/11] fix: address PR review quick fixes - Use model_validate({}) instead of type ignore for Credentials - Fix error message wording in Client.stream() - Remove error handling in entry point loading (fail-fast approach) --- src/celeste/__init__.py | 16 +++------------- src/celeste/client.py | 2 +- src/celeste/credentials.py | 2 +- 3 files changed, 5 insertions(+), 15 deletions(-) diff --git a/src/celeste/__init__.py b/src/celeste/__init__.py index 370ec669..1be158b8 100644 --- a/src/celeste/__init__.py +++ b/src/celeste/__init__.py @@ -86,19 +86,9 @@ def _load_from_entry_points() -> None: entry_points = importlib.metadata.entry_points(group="celeste.packages") for ep in entry_points: - try: - register_func = ep.load() - # The function should register models and clients when called - register_func() - except (ImportError, AttributeError, ValueError) as e: - # Import-time errors should be visible even without logging configuration - import sys - - print( - f"Warning: Failed to load package '{ep.name}': {e}", - file=sys.stderr, - ) - logger.debug(f"Full traceback for package '{ep.name}'", exc_info=True) + register_func = ep.load() + # The function should register models and clients when called + register_func() # Load from entry points on import diff --git a/src/celeste/client.py b/src/celeste/client.py index 704a1b9c..9596fce2 100644 --- a/src/celeste/client.py +++ b/src/celeste/client.py @@ -94,7 +94,7 @@ def stream(self, *args: Any, **parameters: Unpack[Parameters]) -> Stream[Out]: Raises: NotImplementedError: If capability doesn't support streaming. """ - msg = f"{self.capability.value} or {self.provider.value} does not support streaming" + msg = f"Streaming not supported for {self.capability.value} with provider {self.provider.value}" raise NotImplementedError(msg) @abstractmethod diff --git a/src/celeste/credentials.py b/src/celeste/credentials.py index f462bc81..664d240d 100644 --- a/src/celeste/credentials.py +++ b/src/celeste/credentials.py @@ -103,6 +103,6 @@ def has_credential(self, provider: Provider) -> bool: return getattr(self, credential_field, None) is not None -credentials = Credentials() # type: ignore[call-arg] +credentials = Credentials.model_validate({}) __all__ = ["Credentials", "credentials"] From f76f72c402ac7e667b07fa84c518e220aab4bcd2 Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Tue, 4 Nov 2025 14:47:40 +0100 Subject: [PATCH 10/11] fix: address PR review improvements - Export close_all_http_clients() for explicit cleanup - Move logging import to module level in http.py - Use math.isclose() for robust float comparison in Range constraint - Improve type annotation: TracebackType | None in streaming.py --- src/celeste/__init__.py | 3 ++- src/celeste/constraints.py | 5 ++++- src/celeste/http.py | 7 +++---- src/celeste/streaming.py | 3 ++- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/celeste/__init__.py b/src/celeste/__init__.py index 1be158b8..7a0038a6 100644 --- a/src/celeste/__init__.py +++ b/src/celeste/__init__.py @@ -6,7 +6,7 @@ from celeste.client import Client, get_client_class, register_client from celeste.core import Capability, Parameter, Provider from celeste.credentials import credentials -from celeste.http import HTTPClient +from celeste.http import HTTPClient, close_all_http_clients from celeste.io import Input, Output, Usage from celeste.models import Model, get_model, list_models, register_models from celeste.parameters import Parameters @@ -106,6 +106,7 @@ def _load_from_entry_points() -> None: "Parameters", "Provider", "Usage", + "close_all_http_clients", "create_client", "get_client_class", "get_model", diff --git a/src/celeste/constraints.py b/src/celeste/constraints.py index 94579c54..4b604a45 100644 --- a/src/celeste/constraints.py +++ b/src/celeste/constraints.py @@ -1,5 +1,6 @@ """Constraint models for parameter validation.""" +import math import re from abc import ABC, abstractmethod from typing import Any, get_args, get_origin @@ -53,7 +54,9 @@ def __call__(self, value: float | int) -> float | int: remainder = (value - self.min) % self.step # Use epsilon for floating-point comparison tolerance epsilon = 1e-9 - if remainder > epsilon and abs(remainder - self.step) > epsilon: + if not math.isclose(remainder, 0, abs_tol=epsilon) and not math.isclose( + remainder, self.step, abs_tol=epsilon + ): # Calculate nearest valid values for actionable error message closest_below = self.min + ( int((value - self.min) / self.step) * self.step diff --git a/src/celeste/http.py b/src/celeste/http.py index 3c839065..756ad5e9 100644 --- a/src/celeste/http.py +++ b/src/celeste/http.py @@ -1,6 +1,7 @@ """HTTP client with persistent connection pooling for AI provider APIs.""" import json +import logging from collections.abc import AsyncIterator from typing import Any @@ -9,6 +10,8 @@ from celeste.core import Capability, Provider +logger = logging.getLogger(__name__) + MAX_CONNECTIONS = 20 MAX_KEEPALIVE_CONNECTIONS = 10 DEFAULT_TIMEOUT = 60.0 @@ -173,10 +176,6 @@ def get_http_client(provider: Provider, capability: Capability) -> HTTPClient: async def close_all_http_clients() -> None: """Close all HTTP clients gracefully and clear registry.""" - import logging - - logger = logging.getLogger(__name__) - for key, client in list(_http_clients.items()): try: await client.aclose() diff --git a/src/celeste/streaming.py b/src/celeste/streaming.py index 63ed1552..40ad13d2 100644 --- a/src/celeste/streaming.py +++ b/src/celeste/streaming.py @@ -2,6 +2,7 @@ from abc import ABC, abstractmethod from collections.abc import AsyncIterator +from types import TracebackType from typing import Any, Self from celeste.io import Chunk, Output @@ -84,7 +85,7 @@ async def __aexit__( self, exc_type: type[BaseException] | None, exc_val: BaseException | None, - exc_tb: Any, # noqa: ANN401 + exc_tb: TracebackType | None, ) -> bool: """Exit context - ensure cleanup even on exception.""" await self.aclose() From 27bfc94f14ee6b688dc5b1109fe7d4c7c12f4e03 Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Tue, 4 Nov 2025 14:57:16 +0100 Subject: [PATCH 11/11] fix: address minor improvements - Update README logo to GitHub raw URL for PyPI compatibility - Clarify pre-commit config comment for debug-statements hook - Improve Makefile clean command with better error handling and more artifacts --- .pre-commit-config.yaml | 2 +- Makefile | 14 ++++++++------ README.md | 2 +- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 31d00b9b..4906f409 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: - id: check-toml - id: check-merge-conflict - id: check-json - - id: debug-statements # Check for print/pdb statements + - id: debug-statements # Prevents committing print()/pdb.set_trace() debug statements - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.14.3 diff --git a/Makefile b/Makefile index 6e8fbd68..4e71e9c4 100644 --- a/Makefile +++ b/Makefile @@ -68,12 +68,14 @@ ci: @echo "=================================" @echo "๐ŸŽ‰ All CI/CD checks passed! Ready to commit." -# Clean cache directories +# Clean cache directories and artifacts clean: - rm -rf .pytest_cache - rm -rf .mypy_cache - rm -rf .ruff_cache - rm -rf __pycache__ + @echo "๐Ÿงน Cleaning cache directories and artifacts..." + rm -rf .pytest_cache .mypy_cache .ruff_cache .coverage htmlcov/ + rm -rf __pycache__ build/ dist/ *.egg-info .eggs/ find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true - find . -type f -name "*.pyc" -delete + find . -type f -name "*.pyc" -delete 2>/dev/null || true + find . -type f -name "*.pyo" -delete 2>/dev/null || true + find . -type f -name ".coverage" -delete 2>/dev/null || true + @echo "โœ… Clean complete" diff --git a/README.md b/README.md index eef1cd9b..564bef10 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # Celeste AI -Celeste Logo +Celeste Logo **The primitive layer for multi-modal AI**