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..4906f409
--- /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 # Prevents committing print()/pdb.set_trace() debug 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..4e71e9c4
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,81 @@
+.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 and artifacts
+clean:
+ @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 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
new file mode 100644
index 00000000..564bef10
--- /dev/null
+++ b/README.md
@@ -0,0 +1,134 @@
+
+
+# Celeste AI
+
+

+
+**The primitive layer for multi-modal AI**
+
+All capabilities. All providers. One interface.
+
+Primitives, not frameworks.
+
+[](https://www.python.org/)
+[](LICENSE)
+[](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.**
+
+---
+
+
+
+
+

+

+

+

+

+

+

+

+

+

+

+

+

+

+

+
+**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
diff --git a/src/__init__.py b/src/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/celeste/__init__.py b/src/celeste/__init__.py
new file mode 100644
index 00000000..7a0038a6
--- /dev/null
+++ b/src/celeste/__init__.py
@@ -0,0 +1,116 @@
+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, 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
+
+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:
+ register_func = ep.load()
+ # The function should register models and clients when called
+ register_func()
+
+
+# Load from entry points on import
+_load_from_entry_points()
+
+# Exports
+__all__ = [
+ "Capability",
+ "Client",
+ "HTTPClient",
+ "Input",
+ "Model",
+ "Output",
+ "Parameter",
+ "Parameters",
+ "Provider",
+ "Usage",
+ "close_all_http_clients",
+ "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..9596fce2
--- /dev/null
+++ b/src/celeste/client.py
@@ -0,0 +1,150 @@
+"""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"Streaming not supported for {self.capability.value} with provider {self.provider.value}"
+ 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/constraints.py b/src/celeste/constraints.py
new file mode 100644
index 00000000..4b604a45
--- /dev/null
+++ b/src/celeste/constraints.py
@@ -0,0 +1,180 @@
+"""Constraint models for parameter validation."""
+
+import math
+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 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
+ )
+ 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..91df5caf
--- /dev/null
+++ b/src/celeste/core.py
@@ -0,0 +1,52 @@
+"""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 most capabilities."""
+
+ TEMPERATURE = "temperature"
+ SEED = "seed"
+ MAX_TOKENS = "max_tokens"
+
+
+__all__ = ["Capability", "Parameter", "Provider"]
diff --git a/src/celeste/credentials.py b/src/celeste/credentials.py
new file mode 100644
index 00000000..664d240d
--- /dev/null
+++ b/src/celeste/credentials.py
@@ -0,0 +1,108 @@
+"""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):
+ msg = f"Provider {provider} has no credentials configured."
+ raise ValueError(msg)
+
+ 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:
+ msg = f"Provider {provider} has no credential mapping"
+ raise ValueError(msg)
+ return getattr(self, credential_field, None) is not None
+
+
+credentials = Credentials.model_validate({})
+
+__all__ = ["Credentials", "credentials"]
diff --git a/src/celeste/http.py b/src/celeste/http.py
new file mode 100644
index 00000000..756ad5e9
--- /dev/null
+++ b/src/celeste/http.py
@@ -0,0 +1,201 @@
+"""HTTP client with persistent connection pooling for AI provider APIs."""
+
+import json
+import logging
+from collections.abc import AsyncIterator
+from typing import Any
+
+import httpx
+from httpx_sse import aconnect_sse
+
+from celeste.core import Capability, Provider
+
+logger = logging.getLogger(__name__)
+
+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."""
+ 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/io.py b/src/celeste/io.py
new file mode 100644
index 00000000..12974f3d
--- /dev/null
+++ b/src/celeste/io.py
@@ -0,0 +1,43 @@
+"""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."""
+
+ pass
+
+
+class FinishReason(BaseModel):
+ """Base class for capability-specific finish reasons (used in streaming chunks)."""
+
+ pass
+
+
+class Usage(BaseModel):
+ """Base class for capability-specific usage metrics."""
+
+ pass
+
+
+class Output[Content](BaseModel):
+ """Base output class with generic content type."""
+
+ 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."""
+
+ 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"]
diff --git a/src/celeste/models.py b/src/celeste/models.py
new file mode 100644
index 00000000..49f48f03
--- /dev/null
+++ b/src/celeste/models.py
@@ -0,0 +1,91 @@
+"""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"]
diff --git a/src/celeste/parameters.py b/src/celeste/parameters.py
new file mode 100644
index 00000000..47c62e35
--- /dev/null
+++ b/src/celeste/parameters.py
@@ -0,0 +1,51 @@
+"""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/py.typed b/src/celeste/py.typed
new file mode 100644
index 00000000..321d0ae1
--- /dev/null
+++ b/src/celeste/py.typed
@@ -0,0 +1 @@
+# Marker file for PEP 561 - this package supports type checking
diff --git a/src/celeste/streaming.py b/src/celeste/streaming.py
new file mode 100644
index 00000000..40ad13d2
--- /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 types import TracebackType
+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: TracebackType | None,
+ ) -> 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"]