From 7c9cc3208a3c64a99bb9975186236db36dde8be8 Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Tue, 24 Feb 2026 15:40:38 +0100 Subject: [PATCH 1/3] feat(huggingface): add text generation provider via OpenAI-compatible router MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add HuggingFace as a text generation provider using their inference router at router.huggingface.co, which serves 127+ models across 15 inference providers. Follows the ChatCompletionsClient pattern (same as Groq/DeepSeek). Closes #165 Co-Authored-By: Léo Lejeune <61250444+Leo-le-jeune@users.noreply.github.com> --- .env.example | 17 +++- src/celeste/credentials.py | 2 +- src/celeste/modalities/text/models.py | 2 + .../modalities/text/providers/__init__.py | 2 + .../text/providers/huggingface/__init__.py | 6 ++ .../text/providers/huggingface/client.py | 96 +++++++++++++++++++ .../text/providers/huggingface/models.py | 35 +++++++ .../text/providers/huggingface/parameters.py | 41 ++++++++ src/celeste/providers/__init__.py | 2 + src/celeste/providers/huggingface/__init__.py | 11 +++ .../providers/huggingface/chat/__init__.py | 1 + .../providers/huggingface/chat/client.py | 24 +++++ .../providers/huggingface/chat/config.py | 13 +++ .../providers/huggingface/chat/parameters.py | 65 +++++++++++++ .../providers/huggingface/chat/streaming.py | 14 +++ src/celeste/providers/huggingface/py.typed | 0 16 files changed, 329 insertions(+), 2 deletions(-) create mode 100644 src/celeste/modalities/text/providers/huggingface/__init__.py create mode 100644 src/celeste/modalities/text/providers/huggingface/client.py create mode 100644 src/celeste/modalities/text/providers/huggingface/models.py create mode 100644 src/celeste/modalities/text/providers/huggingface/parameters.py create mode 100644 src/celeste/providers/huggingface/__init__.py create mode 100644 src/celeste/providers/huggingface/chat/__init__.py create mode 100644 src/celeste/providers/huggingface/chat/client.py create mode 100644 src/celeste/providers/huggingface/chat/config.py create mode 100644 src/celeste/providers/huggingface/chat/parameters.py create mode 100644 src/celeste/providers/huggingface/chat/streaming.py create mode 100644 src/celeste/providers/huggingface/py.typed diff --git a/.env.example b/.env.example index 0729bccd..8bedaa9a 100644 --- a/.env.example +++ b/.env.example @@ -17,7 +17,7 @@ GOOGLE_API_KEY=your-google-api-key-here MISTRAL_API_KEY=your-mistral-api-key-here # Hugging Face -HUGGINGFACE_TOKEN=your-huggingface-token-here +HF_TOKEN=your-huggingface-token-here # Stability AI STABILITYAI_API_KEY=your-stabilityai-api-key-here @@ -31,6 +31,21 @@ COHERE_API_KEY=your-cohere-api-key-here # xAI XAI_API_KEY=your-xai-api-key-here +# BytePlus +BYTEPLUS_API_KEY=your-byteplus-api-key-here + +# ElevenLabs +ELEVENLABS_API_KEY=your-elevenlabs-api-key-here + +# Groq +GROQ_API_KEY=your-groq-api-key-here + +# Moonshot +MOONSHOT_API_KEY=your-moonshot-api-key-here + +# DeepSeek +DEEPSEEK_API_KEY=your-deepseek-api-key-here + # Luma LUMA_API_KEY=your-luma-api-key-here diff --git a/src/celeste/credentials.py b/src/celeste/credentials.py index bb9e1e13..2feb118f 100644 --- a/src/celeste/credentials.py +++ b/src/celeste/credentials.py @@ -61,7 +61,7 @@ class Credentials(BaseSettings): google_api_key: SecretStr | None = Field(None, alias="GOOGLE_API_KEY") mistral_api_key: SecretStr | None = Field(None, alias="MISTRAL_API_KEY") moonshot_api_key: SecretStr | None = Field(None, alias="MOONSHOT_API_KEY") - huggingface_token: SecretStr | None = Field(None, alias="HUGGINGFACE_TOKEN") + hf_token: SecretStr | None = Field(None, alias="HF_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") diff --git a/src/celeste/modalities/text/models.py b/src/celeste/modalities/text/models.py index 87147504..04256a76 100644 --- a/src/celeste/modalities/text/models.py +++ b/src/celeste/modalities/text/models.py @@ -7,6 +7,7 @@ from .providers.deepseek.models import MODELS as DEEPSEEK_MODELS from .providers.google.models import MODELS as GOOGLE_MODELS from .providers.groq.models import MODELS as GROQ_MODELS +from .providers.huggingface.models import MODELS as HUGGINGFACE_MODELS from .providers.mistral.models import MODELS as MISTRAL_MODELS from .providers.moonshot.models import MODELS as MOONSHOT_MODELS from .providers.ollama.models import MODELS as OLLAMA_MODELS @@ -19,6 +20,7 @@ *DEEPSEEK_MODELS, *GOOGLE_MODELS, *GROQ_MODELS, + *HUGGINGFACE_MODELS, *OLLAMA_MODELS, *MISTRAL_MODELS, *MOONSHOT_MODELS, diff --git a/src/celeste/modalities/text/providers/__init__.py b/src/celeste/modalities/text/providers/__init__.py index 679a1236..ff31c56b 100644 --- a/src/celeste/modalities/text/providers/__init__.py +++ b/src/celeste/modalities/text/providers/__init__.py @@ -8,6 +8,7 @@ from .deepseek import DeepSeekTextClient from .google import GoogleTextClient from .groq import GroqTextClient +from .huggingface import HuggingFaceTextClient from .mistral import MistralTextClient from .moonshot import MoonshotTextClient from .ollama import OllamaTextClient @@ -21,6 +22,7 @@ Provider.DEEPSEEK: DeepSeekTextClient, Provider.GOOGLE: GoogleTextClient, Provider.GROQ: GroqTextClient, + Provider.HUGGINGFACE: HuggingFaceTextClient, Provider.OLLAMA: OllamaTextClient, Provider.OPENRESPONSES: OpenResponsesTextClient, Provider.MISTRAL: MistralTextClient, diff --git a/src/celeste/modalities/text/providers/huggingface/__init__.py b/src/celeste/modalities/text/providers/huggingface/__init__.py new file mode 100644 index 00000000..429fca3f --- /dev/null +++ b/src/celeste/modalities/text/providers/huggingface/__init__.py @@ -0,0 +1,6 @@ +"""HuggingFace provider for text modality.""" + +from .client import HuggingFaceTextClient +from .models import MODELS + +__all__ = ["MODELS", "HuggingFaceTextClient"] diff --git a/src/celeste/modalities/text/providers/huggingface/client.py b/src/celeste/modalities/text/providers/huggingface/client.py new file mode 100644 index 00000000..8c47a959 --- /dev/null +++ b/src/celeste/modalities/text/providers/huggingface/client.py @@ -0,0 +1,96 @@ +"""HuggingFace text client (modality).""" + +from typing import Any, Unpack + +from celeste.parameters import ParameterMapper +from celeste.providers.huggingface.chat.client import HuggingFaceChatClient +from celeste.providers.huggingface.chat.streaming import ( + HuggingFaceChatStream as _HuggingFaceChatStream, +) +from celeste.types import ImageContent, Message, TextContent, VideoContent +from celeste.utils import build_image_data_url + +from ...client import TextClient +from ...io import ( + TextInput, + TextOutput, +) +from ...parameters import TextParameters +from ...streaming import TextStream +from .parameters import HUGGINGFACE_PARAMETER_MAPPERS + + +class HuggingFaceTextStream(_HuggingFaceChatStream, TextStream): + """HuggingFace streaming for text modality.""" + + +class HuggingFaceTextClient(HuggingFaceChatClient, TextClient): + """HuggingFace text client.""" + + @classmethod + def parameter_mappers(cls) -> list[ParameterMapper]: + return HUGGINGFACE_PARAMETER_MAPPERS + + async def generate( + self, + prompt: str | None = None, + *, + messages: list[Message] | None = None, + **parameters: Unpack[TextParameters], + ) -> TextOutput: + """Generate text from prompt.""" + inputs = TextInput(prompt=prompt, messages=messages) + return await self._predict(inputs, **parameters) + + async def analyze( + self, + prompt: str | None = None, + *, + messages: list[Message] | None = None, + image: ImageContent | None = None, + video: VideoContent | None = None, + **parameters: Unpack[TextParameters], + ) -> TextOutput: + """Analyze image(s) or video(s) with prompt or messages.""" + inputs = TextInput(prompt=prompt, messages=messages, image=image, video=video) + return await self._predict(inputs, **parameters) + + def _init_request(self, inputs: TextInput) -> dict[str, Any]: + """Initialize request from HuggingFace messages array format.""" + # If messages provided, use them directly (messages take precedence) + if inputs.messages is not None: + return {"messages": [message.model_dump() for message in inputs.messages]} + + # Fall back to prompt-based input + if inputs.image is None: + content: str | list[dict[str, Any]] = inputs.prompt or "" + else: + images = inputs.image if isinstance(inputs.image, list) else [inputs.image] + content = [ + { + "type": "image_url", + "image_url": {"url": build_image_data_url(img)}, + } + for img in images + ] + content.append({"type": "text", "text": inputs.prompt or ""}) + + return {"messages": [{"role": "user", "content": content}]} + + def _parse_content( + self, + response_data: dict[str, Any], + **parameters: Unpack[TextParameters], + ) -> TextContent: + """Parse content from response.""" + choices = super()._parse_content(response_data) + message = choices[0].get("message", {}) + content = message.get("content") or "" + return self._transform_output(content, **parameters) + + def _stream_class(self) -> type[TextStream]: + """Return the Stream class for this provider.""" + return HuggingFaceTextStream + + +__all__ = ["HuggingFaceTextClient", "HuggingFaceTextStream"] diff --git a/src/celeste/modalities/text/providers/huggingface/models.py b/src/celeste/modalities/text/providers/huggingface/models.py new file mode 100644 index 00000000..a1d92e7c --- /dev/null +++ b/src/celeste/modalities/text/providers/huggingface/models.py @@ -0,0 +1,35 @@ +"""HuggingFace models for text modality.""" + +from celeste.constraints import ImagesConstraint, Range, Schema +from celeste.core import Modality, Operation, Parameter, Provider +from celeste.models import Model + +from ...parameters import TextParameter + +MODELS: list[Model] = [ + Model( + id="Qwen/Qwen3-4B-Instruct-2507", + provider=Provider.HUGGINGFACE, + display_name="Qwen 3 4B Instruct", + operations={Modality.TEXT: {Operation.GENERATE}}, + streaming=True, + parameter_constraints={ + Parameter.TEMPERATURE: Range(min=0.0, max=2.0, step=0.01), + Parameter.MAX_TOKENS: Range(min=1, max=32768, step=1), + TextParameter.OUTPUT_SCHEMA: Schema(), + }, + ), + Model( + id="google/gemma-3n-E4B-it", + provider=Provider.HUGGINGFACE, + display_name="Gemma 3n E4B IT", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, + streaming=True, + parameter_constraints={ + Parameter.TEMPERATURE: Range(min=0.0, max=2.0, step=0.01), + Parameter.MAX_TOKENS: Range(min=1, max=32768, step=1), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.IMAGE: ImagesConstraint(), + }, + ), +] diff --git a/src/celeste/modalities/text/providers/huggingface/parameters.py b/src/celeste/modalities/text/providers/huggingface/parameters.py new file mode 100644 index 00000000..bd0aa238 --- /dev/null +++ b/src/celeste/modalities/text/providers/huggingface/parameters.py @@ -0,0 +1,41 @@ +"""HuggingFace parameter mappers for text.""" + +from celeste.parameters import ParameterMapper +from celeste.protocols.chatcompletions.parameters import ( + MaxTokensMapper as _MaxTokensMapper, +) +from celeste.protocols.chatcompletions.parameters import ( + TemperatureMapper as _TemperatureMapper, +) +from celeste.providers.huggingface.chat.parameters import ( + ResponseFormatMapper as _ResponseFormatMapper, +) + +from ...parameters import TextParameter + + +class TemperatureMapper(_TemperatureMapper): + """Map temperature to HuggingFace's temperature parameter.""" + + name = TextParameter.TEMPERATURE + + +class MaxTokensMapper(_MaxTokensMapper): + """Map max_tokens to HuggingFace's max_tokens parameter.""" + + name = TextParameter.MAX_TOKENS + + +class OutputSchemaMapper(_ResponseFormatMapper): + """Map output_schema to HuggingFace's response_format parameter.""" + + name = TextParameter.OUTPUT_SCHEMA + + +HUGGINGFACE_PARAMETER_MAPPERS: list[ParameterMapper] = [ + TemperatureMapper(), + MaxTokensMapper(), + OutputSchemaMapper(), +] + +__all__ = ["HUGGINGFACE_PARAMETER_MAPPERS"] diff --git a/src/celeste/providers/__init__.py b/src/celeste/providers/__init__.py index a542872e..e9cbafc2 100644 --- a/src/celeste/providers/__init__.py +++ b/src/celeste/providers/__init__.py @@ -10,6 +10,7 @@ google, gradium, groq, + huggingface, mistral, moonshot, ollama, @@ -27,6 +28,7 @@ "google", "gradium", "groq", + "huggingface", "mistral", "moonshot", "ollama", diff --git a/src/celeste/providers/huggingface/__init__.py b/src/celeste/providers/huggingface/__init__.py new file mode 100644 index 00000000..1cbdca20 --- /dev/null +++ b/src/celeste/providers/huggingface/__init__.py @@ -0,0 +1,11 @@ +"""HuggingFace provider package for Celeste AI.""" + +from celeste.core import Provider +from celeste.credentials import register_auth + +register_auth( # nosec B106 - env var name, not hardcoded password + provider=Provider.HUGGINGFACE, + secret_name="HF_TOKEN", + header="Authorization", + prefix="Bearer ", +) diff --git a/src/celeste/providers/huggingface/chat/__init__.py b/src/celeste/providers/huggingface/chat/__init__.py new file mode 100644 index 00000000..90cc3cc2 --- /dev/null +++ b/src/celeste/providers/huggingface/chat/__init__.py @@ -0,0 +1 @@ +"""HuggingFace Chat API provider package.""" diff --git a/src/celeste/providers/huggingface/chat/client.py b/src/celeste/providers/huggingface/chat/client.py new file mode 100644 index 00000000..3f6a055a --- /dev/null +++ b/src/celeste/providers/huggingface/chat/client.py @@ -0,0 +1,24 @@ +"""HuggingFace Chat API client mixin.""" + +from typing import ClassVar + +from celeste.protocols.chatcompletions import ChatCompletionsClient + +from . import config + + +class HuggingFaceChatClient(ChatCompletionsClient): + """Mixin for HuggingFace Chat API capabilities. + + Inherits shared Chat Completions implementation. Only overrides: + - _default_base_url - HuggingFace router base URL + - _default_endpoint - HuggingFace uses /v1/chat/completions + """ + + _default_base_url: ClassVar[str] = config.BASE_URL + _default_endpoint: ClassVar[str] = ( + config.HuggingFaceChatEndpoint.CREATE_CHAT_COMPLETION + ) + + +__all__ = ["HuggingFaceChatClient"] diff --git a/src/celeste/providers/huggingface/chat/config.py b/src/celeste/providers/huggingface/chat/config.py new file mode 100644 index 00000000..7cc8eb45 --- /dev/null +++ b/src/celeste/providers/huggingface/chat/config.py @@ -0,0 +1,13 @@ +"""Configuration for HuggingFace Chat API.""" + +from enum import StrEnum + + +class HuggingFaceChatEndpoint(StrEnum): + """Endpoints for HuggingFace Chat API.""" + + CREATE_CHAT_COMPLETION = "/v1/chat/completions" + LIST_MODELS = "/v1/models" + + +BASE_URL = "https://router.huggingface.co" diff --git a/src/celeste/providers/huggingface/chat/parameters.py b/src/celeste/providers/huggingface/chat/parameters.py new file mode 100644 index 00000000..6709d994 --- /dev/null +++ b/src/celeste/providers/huggingface/chat/parameters.py @@ -0,0 +1,65 @@ +"""HuggingFace Chat API parameter mappers.""" + +from typing import Any, get_args, get_origin + +from pydantic import TypeAdapter + +from celeste.models import Model +from celeste.protocols.chatcompletions.parameters import ( + ResponseFormatMapper as _ResponseFormatMapper, +) +from celeste.structured_outputs import StrictJsonSchemaGenerator + + +class ResponseFormatMapper(_ResponseFormatMapper): + """Map output_schema to HuggingFace response_format field (json_schema mode). + + Handles both single BaseModel and list[BaseModel] types. + HuggingFace requires top-level object, so lists are wrapped in {items: []}. + Uses json_schema mode with strict: true. + """ + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform output_schema into HuggingFace response_format.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + origin = get_origin(validated_value) + if origin is list: + # HuggingFace requires top-level object, wrap list in {"items": [...]} + inner_type = get_args(validated_value)[0] + inner_schema = TypeAdapter(inner_type).json_schema( + schema_generator=StrictJsonSchemaGenerator, + mode="serialization", + ) + schema = { + "type": "object", + "properties": {"items": {"type": "array", "items": inner_schema}}, + "required": ["items"], + } + name = f"{inner_type.__name__.lower()}_list" + else: + schema = TypeAdapter(validated_value).json_schema( + schema_generator=StrictJsonSchemaGenerator, + mode="serialization", + ) + name = validated_value.__name__.lower() + + request["response_format"] = { + "type": "json_schema", + "json_schema": { + "name": name, + "strict": True, + "schema": schema, + }, + } + return request + + +__all__ = ["ResponseFormatMapper"] diff --git a/src/celeste/providers/huggingface/chat/streaming.py b/src/celeste/providers/huggingface/chat/streaming.py new file mode 100644 index 00000000..97330659 --- /dev/null +++ b/src/celeste/providers/huggingface/chat/streaming.py @@ -0,0 +1,14 @@ +"""HuggingFace Chat SSE parsing for streaming.""" + +from celeste.protocols.chatcompletions import ChatCompletionsStream + + +class HuggingFaceChatStream(ChatCompletionsStream): + """Mixin for Chat API SSE parsing. + + Inherits shared Chat Completions streaming implementation. + No overrides needed — standard Chat Completions SSE format. + """ + + +__all__ = ["HuggingFaceChatStream"] diff --git a/src/celeste/providers/huggingface/py.typed b/src/celeste/providers/huggingface/py.typed new file mode 100644 index 00000000..e69de29b From f226dfd4e2aabbfc35f647aee7fc6d7dad499a7e Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Tue, 24 Feb 2026 15:44:49 +0100 Subject: [PATCH 2/3] ci: add HF_TOKEN to integration test environment --- .github/workflows/publish.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8886f58c..46d801ca 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -90,6 +90,7 @@ jobs: GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }} MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }} DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + HF_TOKEN: ${{ secrets.HF_TOKEN }} run: | uv run pytest "$TEST_FILE" \ -m integration -v \ From 933d8d2c52f71a1251b630f3b9be967248a5e021 Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Tue, 24 Feb 2026 16:39:10 +0100 Subject: [PATCH 3/3] release: bump version to 0.10.2 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 92e54959..3aa3abe1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "celeste-ai" -version = "0.10.1" +version = "0.10.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"