From d7c6d80cceb531273a7ee58baddf824b0dcf47b3 Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Thu, 15 Jan 2026 16:51:33 +0100 Subject: [PATCH] feat: v1 architecture migration - modality-centric API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This release migrates from capability-centric (v0.3.x) to modality-centric (v1) architecture. ## Architecture Shift - Clients now organized by output modality (text/images/audio/videos/embeddings) - Operations become methods on modality clients: generate, edit, analyze, embed, speak - Domain = resource you work with; Modality = output type - Cross-domain operations explicit: image/audio/video analysis → text modality ## Namespace API (recommended) - Domain-first entry points: celeste.text, celeste.images, celeste.audio, celeste.videos - One-line calls with IDE autocomplete: celeste.images.generate(model="...", prompt="...") - Execution modes: async default, .sync for blocking, .stream for streaming ## create_client (advanced) - Modality-first for explicit control: create_client(Modality.TEXT, Operation.GENERATE) - Full configuration and client reuse when needed ## Single Package Install - pip install celeste-ai (no extras required) - Removed multi-package structure (packages/ directory deleted) - Faster startup: no entry-point discovery - Simpler maintenance: one version, one release pipeline ## Provider Support - All providers bundled: OpenAI, Anthropic, Google, Mistral, Cohere, xAI, ElevenLabs, BFL, Gradium, BytePlus, DeepSeek, Groq, Moonshot - No heavyweight vendor SDKs - lightweight HTTP clients only ## New Operations - images.edit: Edit images with AI (OpenAI gpt-image-1) - images.analyze: Vision/image understanding → text output - audio.analyze: Audio transcription/understanding → text output - videos.analyze: Video understanding → text output ## API Changes - extra_body parameter for provider-specific options - Artifact API: get_bytes(), get_base64() replace to_data_url() - Structured outputs via JSON schema generators ## Files - Added: src/celeste/modalities/, src/celeste/namespaces/, src/celeste/providers/, src/celeste/utils/ - Removed: packages/, src/celeste/registry.py, src/celeste/utils.py - Updated: all core modules, tests, CI/CD workflows See CHANGELOG_V1.md for complete migration details. --- .github/workflows/ci.yml | 28 +- .github/workflows/publish.yml | 88 +- .gitignore | 4 - CHANGELOG_V1.md | 154 +++ Makefile | 16 +- README.md | 115 ++- .../capabilities/image-generation/README.md | 79 -- .../image-generation/pyproject.toml | 50 - .../src/celeste_image_generation/__init__.py | 38 - .../src/celeste_image_generation/client.py | 78 -- .../celeste_image_generation/constraints.py | 72 -- .../src/celeste_image_generation/io.py | 61 -- .../src/celeste_image_generation/models.py | 16 - .../celeste_image_generation/parameters.py | 40 - .../providers/__init__.py | 32 - .../providers/bfl/__init__.py | 6 - .../providers/bfl/client.py | 67 -- .../providers/byteplus/__init__.py | 6 - .../providers/byteplus/client.py | 107 --- .../providers/byteplus/parameters.py | 57 -- .../providers/byteplus/streaming.py | 81 -- .../providers/google/__init__.py | 11 - .../providers/google/client.py | 82 -- .../providers/google/gemini.py | 90 -- .../providers/google/gemini_parameters.py | 35 - .../providers/google/imagen.py | 90 -- .../providers/google/imagen_parameters.py | 35 - .../providers/google/parameters.py | 12 - .../providers/openai/__init__.py | 7 - .../providers/openai/client.py | 80 -- .../providers/openai/parameters.py | 35 - .../providers/openai/streaming.py | 60 -- .../src/celeste_image_generation/streaming.py | 55 -- .../test_image_generation/__init__.py | 1 - .../test_image_generation/test_generate.py | 62 -- .../unit_tests/providers/google/__init__.py | 1 - .../providers/google/test_finish_reason.py | 166 ---- .../capabilities/speech-generation/README.md | 79 -- .../speech-generation/pyproject.toml | 50 - .../src/celeste_speech_generation/__init__.py | 62 -- .../src/celeste_speech_generation/client.py | 69 -- .../src/celeste_speech_generation/io.py | 48 - .../src/celeste_speech_generation/models.py | 16 - .../celeste_speech_generation/parameters.py | 24 - .../providers/__init__.py | 32 - .../providers/elevenlabs/__init__.py | 11 - .../providers/elevenlabs/client.py | 97 -- .../providers/elevenlabs/models.py | 141 --- .../providers/elevenlabs/parameters.py | 43 - .../providers/elevenlabs/streaming.py | 44 - .../providers/google/__init__.py | 6 - .../providers/google/client.py | 79 -- .../providers/google/mappings.py | 37 - .../providers/google/parameters.py | 47 - .../providers/gradium/__init__.py | 9 - .../providers/gradium/client.py | 93 -- .../providers/gradium/parameters.py | 60 -- .../providers/openai/__init__.py | 6 - .../providers/openai/client.py | 88 -- .../providers/openai/models.py | 61 -- .../providers/openai/parameters.py | 35 - .../src/celeste_speech_generation/py.typed | 1 - .../celeste_speech_generation/streaming.py | 44 - .../test_speech_generation/__init__.py | 1 - .../test_speech_generation/test_generate.py | 72 -- .../test_speech_generation/test_stream.py | 59 -- .../capabilities/text-generation/README.md | 81 -- .../text-generation/pyproject.toml | 54 -- .../src/celeste_text_generation/__init__.py | 38 - .../src/celeste_text_generation/client.py | 78 -- .../src/celeste_text_generation/io.py | 60 -- .../src/celeste_text_generation/models.py | 20 - .../src/celeste_text_generation/parameters.py | 27 - .../providers/__init__.py | 40 - .../providers/anthropic/__init__.py | 7 - .../providers/anthropic/client.py | 65 -- .../providers/anthropic/models.py | 72 -- .../providers/anthropic/streaming.py | 95 -- .../providers/cohere/__init__.py | 7 - .../providers/cohere/client.py | 73 -- .../providers/cohere/models.py | 33 - .../providers/cohere/streaming.py | 87 -- .../providers/google/__init__.py | 7 - .../providers/google/client.py | 67 -- .../providers/google/models.py | 76 -- .../providers/google/parameters.py | 52 - .../providers/google/streaming.py | 83 -- .../providers/mistral/__init__.py | 7 - .../providers/mistral/client.py | 95 -- .../providers/mistral/streaming.py | 87 -- .../providers/openai/__init__.py | 7 - .../providers/openai/client.py | 65 -- .../providers/openai/parameters.py | 52 - .../providers/openai/streaming.py | 85 -- .../providers/xai/__init__.py | 7 - .../providers/xai/client.py | 65 -- .../providers/xai/models.py | 76 -- .../providers/xai/parameters.py | 44 - .../providers/xai/streaming.py | 85 -- .../src/celeste_text_generation/py.typed | 1 - .../src/celeste_text_generation/streaming.py | 46 - .../tests/integration_tests/conftest.py | 19 - .../test_text_generation/__init__.py | 1 - .../test_text_generation/test_generate.py | 58 -- .../test_text_generation/test_stream.py | 107 --- .../capabilities/video-generation/README.md | 79 -- .../video-generation/pyproject.toml | 49 - .../src/celeste_video_generation/__init__.py | 32 - .../src/celeste_video_generation/client.py | 71 -- .../src/celeste_video_generation/io.py | 35 - .../src/celeste_video_generation/models.py | 14 - .../celeste_video_generation/parameters.py | 28 - .../providers/__init__.py | 28 - .../providers/byteplus/README.md | 13 - .../providers/byteplus/__init__.py | 11 - .../providers/byteplus/client.py | 168 ---- .../providers/byteplus/parameters.py | 51 - .../providers/google/__init__.py | 10 - .../providers/google/client.py | 76 -- .../providers/google/models.py | 78 -- .../providers/google/parameters.py | 59 -- .../providers/openai/__init__.py | 10 - .../providers/openai/client.py | 140 --- .../providers/openai/models.py | 46 - .../providers/openai/parameters.py | 78 -- .../src/celeste_video_generation/py.typed | 1 - .../test_video_generation/__init__.py | 1 - .../test_video_generation/test_generate.py | 65 -- packages/providers/anthropic/pyproject.toml | 18 - .../src/celeste_anthropic/__init__.py | 3 - .../celeste_anthropic/messages/streaming.py | 76 -- packages/providers/bfl/pyproject.toml | 18 - .../providers/bfl/src/celeste_bfl/__init__.py | 1 - packages/providers/byteplus/pyproject.toml | 18 - .../byteplus/src/celeste_byteplus/__init__.py | 1 - .../src/celeste_byteplus/images/streaming.py | 116 --- packages/providers/cohere/pyproject.toml | 18 - .../cohere/src/celeste_cohere/__init__.py | 3 - .../src/celeste_cohere/chat/streaming.py | 89 -- packages/providers/elevenlabs/pyproject.toml | 18 - .../src/celeste_elevenlabs/__init__.py | 3 - packages/providers/google/pyproject.toml | 21 - .../google/src/celeste_google/__init__.py | 12 - .../generate_content/streaming.py | 54 -- packages/providers/gradium/pyproject.toml | 18 - .../gradium/src/celeste_gradium/__init__.py | 3 - packages/providers/mistral/pyproject.toml | 18 - .../mistral/src/celeste_mistral/__init__.py | 1 - .../src/celeste_mistral/chat/streaming.py | 67 -- packages/providers/openai/pyproject.toml | 35 - .../openai/src/celeste_openai/__init__.py | 1 - .../src/celeste_openai/images/streaming.py | 89 -- .../src/celeste_openai/responses/streaming.py | 68 -- packages/providers/xai/pyproject.toml | 18 - .../providers/xai/src/celeste_xai/__init__.py | 1 - .../src/celeste_xai/responses/streaming.py | 71 -- pyproject.toml | 90 +- src/celeste/__init__.py | 197 ++-- src/celeste/artifacts.py | 35 +- src/celeste/auth.py | 35 +- src/celeste/client.py | 261 ++--- src/celeste/constraints.py | 205 +++- src/celeste/core.py | 88 +- src/celeste/credentials.py | 164 ++-- src/celeste/exceptions.py | 54 +- src/celeste/http.py | 26 +- src/celeste/io.py | 41 +- src/celeste/mime_types.py | 1 + src/celeste/modalities/__init__.py | 5 + src/celeste/modalities/audio/__init__.py | 22 + src/celeste/modalities/audio/client.py | 110 +++ .../celeste/modalities/audio}/constraints.py | 11 +- src/celeste/modalities/audio/io.py | 51 + .../celeste/modalities/audio}/languages.py | 0 src/celeste/modalities/audio/models.py | 15 + src/celeste/modalities/audio/parameters.py | 31 + .../modalities/audio/providers/__init__.py | 16 + .../audio/providers/elevenlabs/__init__.py | 6 + .../audio/providers/elevenlabs/client.py | 144 +++ .../audio/providers/elevenlabs/models.py | 134 +++ .../audio/providers/elevenlabs/parameters.py | 51 + .../audio}/providers/elevenlabs/voices.py | 7 +- .../audio/providers/google/__init__.py | 5 + .../audio/providers/google/client.py | 78 ++ .../audio}/providers/google/models.py | 37 +- .../audio/providers/google/parameters.py | 72 ++ .../audio}/providers/google/voices.py | 13 +- .../audio/providers/gradium/__init__.py | 6 + .../audio/providers/gradium/client.py | 144 +++ .../audio}/providers/gradium/models.py | 18 +- .../audio/providers/gradium/parameters.py | 31 + .../audio}/providers/gradium/voices.py | 7 +- .../audio/providers/openai/__init__.py | 6 + .../audio/providers/openai/client.py | 67 ++ .../audio/providers/openai/models.py | 57 ++ .../audio/providers/openai/parameters.py | 41 + .../audio}/providers/openai/voices.py | 7 +- src/celeste/modalities/audio/streaming.py | 89 ++ .../celeste/modalities/audio}/voices.py | 6 +- src/celeste/modalities/embeddings/__init__.py | 22 + src/celeste/modalities/embeddings/client.py | 83 ++ src/celeste/modalities/embeddings/io.py | 49 + src/celeste/modalities/embeddings/models.py | 9 + .../modalities/embeddings/parameters.py | 23 + .../embeddings/providers/__init__.py | 10 + .../embeddings/providers/google/__init__.py | 6 + .../embeddings/providers/google/client.py | 67 ++ .../embeddings/providers/google/models.py | 19 + .../embeddings/providers/google/parameters.py | 21 + .../celeste/modalities/embeddings}/py.typed | 0 src/celeste/modalities/images/__init__.py | 22 + src/celeste/modalities/images/client.py | 169 ++++ src/celeste/modalities/images/io.py | 57 ++ src/celeste/modalities/images/models.py | 15 + src/celeste/modalities/images/parameters.py | 48 + .../modalities/images/providers/__init__.py | 16 + .../images/providers/bfl/__init__.py | 6 + .../modalities/images/providers/bfl/client.py | 89 ++ .../images}/providers/bfl/models.py | 49 +- .../images}/providers/bfl/parameters.py | 61 +- .../images}/providers/byteplus/README.md | 0 .../images/providers/byteplus/__init__.py | 6 + .../images/providers/byteplus/client.py | 207 ++++ .../images}/providers/byteplus/models.py | 27 +- .../images/providers/byteplus/parameters.py | 34 + .../images/providers/google/__init__.py | 6 + .../images/providers/google/client.py | 111 +++ .../images/providers/google/gemini.py | 133 +++ .../images/providers/google/imagen.py | 82 ++ .../images}/providers/google/models.py | 47 +- .../images/providers/google/parameters.py | 79 ++ .../images/providers/openai/__init__.py | 6 + .../images/providers/openai/client.py | 166 ++++ .../images}/providers/openai/models.py | 41 +- .../images/providers/openai/parameters.py | 41 + src/celeste/modalities/images/streaming.py | 90 ++ src/celeste/modalities/text/__init__.py | 25 + src/celeste/modalities/text/client.py | 236 +++++ src/celeste/modalities/text/io.py | 62 ++ src/celeste/modalities/text/models.py | 25 + src/celeste/modalities/text/parameters.py | 58 ++ .../modalities/text/providers/__init__.py | 26 + .../text/providers/anthropic/__init__.py | 6 + .../text/providers/anthropic/client.py | 191 ++++ .../text/providers/anthropic/models.py | 86 ++ .../text}/providers/anthropic/parameters.py | 37 +- .../text/providers/cohere/__init__.py | 6 + .../text/providers/cohere/client.py | 147 +++ .../text/providers/cohere/models.py | 49 + .../text}/providers/cohere/parameters.py | 35 +- .../text/providers/deepseek/__init__.py | 6 + .../text/providers/deepseek/client.py | 128 +++ .../text/providers/deepseek/models.py | 34 + .../text/providers/deepseek/parameters.py | 41 + .../text/providers/google/__init__.py | 6 + .../text/providers/google/client.py | 201 ++++ .../text/providers/google/models.py | 115 +++ .../text/providers/google/parameters.py | 71 ++ .../text/providers/groq/__init__.py | 6 + .../modalities/text/providers/groq/client.py | 145 +++ .../modalities/text/providers/groq/models.py | 168 ++++ .../text/providers/groq/parameters.py | 41 + .../text/providers/mistral/__init__.py | 6 + .../text/providers/mistral/client.py | 154 +++ .../text}/providers/mistral/models.py | 94 +- .../text}/providers/mistral/parameters.py | 40 +- .../text/providers/moonshot/__init__.py | 6 + .../text/providers/moonshot/client.py | 144 +++ .../text/providers/moonshot/models.py | 83 ++ .../text/providers/moonshot/parameters.py | 41 + .../text/providers/openai/__init__.py | 6 + .../text/providers/openai/client.py | 166 ++++ .../text}/providers/openai/models.py | 109 ++- .../text/providers/openai/parameters.py | 71 ++ .../modalities/text/providers/xai/__init__.py | 6 + .../modalities/text/providers/xai/client.py | 163 ++++ .../modalities/text/providers/xai/models.py | 119 +++ .../text/providers/xai/parameters.py | 81 ++ .../celeste/modalities/text}/py.typed | 0 src/celeste/modalities/text/streaming.py | 85 ++ src/celeste/modalities/videos/__init__.py | 22 + src/celeste/modalities/videos/client.py | 60 ++ src/celeste/modalities/videos/io.py | 52 + src/celeste/modalities/videos/models.py | 13 + src/celeste/modalities/videos/parameters.py | 34 + .../modalities/videos/providers/__init__.py | 14 + .../videos/providers/byteplus/__init__.py | 6 + .../videos/providers/byteplus/client.py | 68 ++ .../videos}/providers/byteplus/models.py | 68 +- .../videos/providers/byteplus/parameters.py | 71 ++ .../videos/providers/google/__init__.py | 6 + .../videos/providers/google/client.py | 81 ++ .../videos/providers/google/models.py | 84 ++ .../videos/providers/google/parameters.py | 71 ++ .../videos/providers/openai/__init__.py | 6 + .../videos/providers/openai/client.py | 70 ++ .../videos/providers/openai/models.py | 51 + .../videos/providers/openai/parameters.py | 116 +++ src/celeste/models.py | 72 +- src/celeste/namespaces/__init__.py | 44 + src/celeste/namespaces/domains.py | 898 ++++++++++++++++++ src/celeste/parameters.py | 6 +- src/celeste/providers/__init__.py | 33 + src/celeste/providers/anthropic/__init__.py | 12 + .../providers/anthropic}/messages/__init__.py | 0 .../providers/anthropic}/messages/client.py | 73 +- .../providers/anthropic}/messages/config.py | 2 +- .../anthropic}/messages/parameters.py | 36 +- .../providers/anthropic/messages/streaming.py | 76 ++ .../celeste/providers/anthropic}/py.typed | 0 src/celeste/providers/api_references.md | 43 + src/celeste/providers/bfl/__init__.py | 11 + .../celeste/providers/bfl}/images/__init__.py | 0 .../celeste/providers/bfl}/images/client.py | 61 +- .../celeste/providers/bfl}/images/config.py | 2 +- .../providers/bfl}/images/parameters.py | 0 .../celeste/providers/bfl}/images/utils.py | 19 +- .../celeste/providers/bfl}/py.typed | 0 src/celeste/providers/byteplus/__init__.py | 12 + .../providers/byteplus}/images/__init__.py | 0 .../providers/byteplus}/images/client.py | 49 +- .../providers/byteplus}/images/config.py | 2 +- .../providers/byteplus}/images/parameters.py | 0 .../providers/byteplus/images/streaming.py | 104 ++ .../celeste/providers/byteplus}/py.typed | 0 .../providers/byteplus}/videos/__init__.py | 0 .../providers/byteplus}/videos/client.py | 79 +- .../providers/byteplus}/videos/config.py | 2 +- .../providers/byteplus}/videos/parameters.py | 0 src/celeste/providers/cohere/__init__.py | 12 + .../providers/cohere}/chat/__init__.py | 0 .../celeste/providers/cohere}/chat/client.py | 50 +- .../celeste/providers/cohere}/chat/config.py | 2 +- .../providers/cohere}/chat/parameters.py | 12 +- .../providers/cohere/chat/streaming.py | 93 ++ .../celeste/providers/cohere}/py.typed | 0 src/celeste/providers/deepseek/__init__.py | 12 + .../providers/deepseek/chat/__init__.py | 1 + src/celeste/providers/deepseek/chat/client.py | 151 +++ src/celeste/providers/deepseek/chat/config.py | 13 + .../providers/deepseek/chat/parameters.py | 97 ++ .../providers/deepseek/chat/streaming.py | 81 ++ .../celeste/providers/deepseek}/py.typed | 0 src/celeste/providers/elevenlabs/__init__.py | 11 + .../celeste/providers/elevenlabs}/py.typed | 0 .../elevenlabs}/text_to_speech/__init__.py | 0 .../elevenlabs}/text_to_speech/client.py | 52 +- .../elevenlabs}/text_to_speech/config.py | 2 +- .../elevenlabs}/text_to_speech/parameters.py | 2 +- .../elevenlabs/text_to_speech/streaming.py | 57 ++ src/celeste/providers/google/__init__.py | 19 + .../celeste/providers/google}/auth.py | 0 .../providers/google}/cloud_tts/__init__.py | 0 .../providers/google}/cloud_tts/client.py | 59 +- .../providers/google}/cloud_tts/config.py | 2 +- .../providers/google}/cloud_tts/parameters.py | 0 .../providers/google/embeddings/__init__.py | 1 + .../providers/google/embeddings/client.py | 112 +++ .../providers/google/embeddings/config.py | 13 + .../providers/google/embeddings/parameters.py | 34 + .../google}/generate_content/__init__.py | 0 .../google}/generate_content/client.py | 35 +- .../google}/generate_content/config.py | 2 +- .../google}/generate_content/parameters.py | 47 +- .../google/generate_content/streaming.py | 69 ++ .../providers/google}/imagen/__init__.py | 0 .../providers/google}/imagen/client.py | 38 +- .../providers/google}/imagen/config.py | 2 +- .../providers/google}/imagen/parameters.py | 0 .../providers/google/interactions/__init__.py | 1 + .../providers/google/interactions/client.py | 172 ++++ .../providers/google/interactions/config.py | 25 + .../google/interactions/parameters.py | 380 ++++++++ .../google/interactions/streaming.py | 110 +++ .../celeste/providers/google}/py.typed | 0 .../celeste/providers/google}/veo/__init__.py | 0 .../celeste/providers/google}/veo/client.py | 55 +- .../celeste/providers/google}/veo/config.py | 2 +- .../providers/google}/veo/parameters.py | 71 +- src/celeste/providers/gradium/__init__.py | 11 + .../celeste/providers/gradium}/py.typed | 0 .../gradium}/text_to_speech/__init__.py | 0 .../gradium}/text_to_speech/client.py | 98 +- .../gradium}/text_to_speech/config.py | 2 +- .../gradium}/text_to_speech/parameters.py | 2 +- .../gradium/text_to_speech/streaming.py | 59 ++ src/celeste/providers/groq/__init__.py | 11 + src/celeste/providers/groq/chat/__init__.py | 1 + src/celeste/providers/groq/chat/client.py | 145 +++ src/celeste/providers/groq/chat/config.py | 14 + src/celeste/providers/groq/chat/parameters.py | 125 +++ src/celeste/providers/groq/chat/streaming.py | 81 ++ .../celeste/providers/groq}/py.typed | 0 src/celeste/providers/mistral/__init__.py | 12 + .../providers/mistral}/chat/__init__.py | 0 .../celeste/providers/mistral}/chat/client.py | 61 +- .../celeste/providers/mistral}/chat/config.py | 2 +- .../providers/mistral}/chat/parameters.py | 35 +- .../providers/mistral/chat/streaming.py | 83 ++ .../celeste/providers/mistral/py.typed | 0 src/celeste/providers/moonshot/__init__.py | 12 + .../providers/moonshot/chat/__init__.py | 1 + src/celeste/providers/moonshot/chat/client.py | 143 +++ src/celeste/providers/moonshot/chat/config.py | 12 + .../providers/moonshot/chat/parameters.py | 97 ++ .../providers/moonshot/chat/streaming.py | 87 ++ .../providers/moonshot/py.typed} | 0 src/celeste/providers/openai/__init__.py | 12 + .../providers/openai}/audio/__init__.py | 0 .../celeste/providers/openai}/audio/client.py | 73 +- .../celeste/providers/openai}/audio/config.py | 2 +- .../providers/openai}/audio/parameters.py | 0 .../providers/openai}/images/__init__.py | 0 .../providers/openai}/images/client.py | 112 ++- .../providers/openai}/images/config.py | 2 +- .../providers/openai}/images/parameters.py | 0 .../providers/openai/images/streaming.py | 88 ++ src/celeste/providers/openai/py.typed | 0 .../providers/openai}/responses/__init__.py | 0 .../providers/openai}/responses/client.py | 48 +- .../providers/openai}/responses/config.py | 2 +- .../providers/openai}/responses/parameters.py | 16 +- .../providers/openai/responses/streaming.py | 71 ++ .../providers/openai}/videos/__init__.py | 0 .../providers/openai}/videos/client.py | 67 +- .../providers/openai}/videos/config.py | 2 +- .../providers/openai}/videos/parameters.py | 14 +- src/celeste/providers/xai/__init__.py | 12 + src/celeste/providers/xai/py.typed | 0 .../providers/xai}/responses/__init__.py | 0 .../providers/xai}/responses/client.py | 56 +- .../providers/xai}/responses/config.py | 2 +- .../providers/xai}/responses/parameters.py | 16 +- .../providers/xai/responses/streaming.py | 71 ++ src/celeste/registry.py | 44 - src/celeste/streaming.py | 91 +- src/celeste/types.py | 17 +- src/celeste/utils.py | 35 - src/celeste/utils/__init__.py | 15 + src/celeste/utils/image.py | 126 +++ src/celeste/utils/mime.py | 85 ++ src/celeste/websocket.py | 14 +- .../__init__.py.template | 25 + .../client.py.template | 214 +++++ .../celeste_{modality_slug}/io.py.template | 62 ++ .../parameters.py.template | 54 ++ .../providers/__init__.py.template | 18 + .../{provider_slug}/__init__.py.template | 6 + .../{provider_slug}/client.py.template | 141 +++ .../{provider_slug}/models.py.template | 33 + .../{provider_slug}/parameters.py.template | 21 + .../streaming.py.template | 88 ++ .../tests/__init__.py.template | 1 + .../integration_tests/__init__.py.template | 1 + .../integration_tests/conftest.py.template | 40 + .../test_{modality_slug}/__init__.py.template | 1 + .../test_stream_{operation_slug}.py.template | 87 ++ .../test_{operation_slug}.py.template | 107 +++ .../__init__.py.template | 17 + .../celeste_{provider_slug}/py.typed.template | 1 + .../{api_slug}/__init__.py.template | 1 + .../{api_slug}/client.py.template | 139 +++ .../{api_slug}/config.py.template | 20 + .../{api_slug}/parameters.py.template | 61 ++ .../{api_slug}/streaming.py.template | 62 ++ tests/__init__.py | 0 tests/integration_tests/__init__.py | 0 tests/integration_tests/audio/__init__.py | 0 tests/integration_tests/audio/test_speak.py | 93 ++ .../audio/test_stream_speak.py | 92 ++ .../integration_tests/conftest.py | 0 .../integration_tests/embeddings/__init__.py | 0 .../embeddings/test_embed.py | 107 +++ tests/integration_tests/images/__init__.py | 0 .../images/assets/square.png | Bin 0 -> 623 bytes tests/integration_tests/images/conftest.py | 18 + tests/integration_tests/images/test_edit.py | 77 ++ .../integration_tests/images/test_generate.py | 76 ++ .../images/test_stream_edit.py | 93 ++ .../images/test_stream_generate.py | 88 ++ tests/integration_tests/text/__init__.py | 0 .../integration_tests/text/assets/square.png | Bin 0 -> 623 bytes .../text/assets/test_audio.mp3 | Bin 0 -> 8782 bytes .../text/assets/test_video.mp4 | Bin 0 -> 2318 bytes tests/integration_tests/text/conftest.py | 34 + .../text/test_analyze_audio.py | 90 ++ .../text/test_analyze_image.py | 98 ++ .../text/test_analyze_video.py | 90 ++ tests/integration_tests/text/test_generate.py | 85 ++ .../text/test_stream_analyze_audio.py | 101 ++ .../text/test_stream_analyze_image.py | 109 +++ .../text/test_stream_analyze_video.py | 101 ++ .../text/test_stream_generate.py | 92 ++ tests/integration_tests/videos/__init__.py | 0 .../integration_tests/videos/test_generate.py | 90 ++ tests/testing_guidelines.md | 261 +++++ tests/unit_tests/__init__.py | 0 tests/unit_tests/test_artifacts.py | 86 ++ tests/unit_tests/test_auth.py | 102 ++ tests/unit_tests/test_client.py | 384 ++------ tests/unit_tests/test_constraints.py | 286 +++++- tests/unit_tests/test_credentials.py | 115 ++- tests/unit_tests/test_exceptions.py | 119 +++ tests/unit_tests/test_http.py | 46 +- tests/unit_tests/test_init.py | 322 ++++++- tests/unit_tests/test_io.py | 93 ++ tests/unit_tests/test_models.py | 85 +- tests/unit_tests/test_parameters.py | 142 ++- .../unit_tests/test_provider_api_templates.py | 203 ++++ ...test_stream_metadata_from_response_data.py | 194 ++++ tests/unit_tests/test_streaming.py | 57 +- .../test_text_media_support_validation.py | 187 ++++ .../test_text_modality_analyze_image.py | 264 +++++ tests/unit_tests/utils/__init__.py | 1 + tests/unit_tests/utils/test_image.py | 229 +++++ 515 files changed, 18751 insertions(+), 8960 deletions(-) create mode 100644 CHANGELOG_V1.md delete mode 100644 packages/capabilities/image-generation/README.md delete mode 100644 packages/capabilities/image-generation/pyproject.toml delete mode 100644 packages/capabilities/image-generation/src/celeste_image_generation/__init__.py delete mode 100644 packages/capabilities/image-generation/src/celeste_image_generation/client.py delete mode 100644 packages/capabilities/image-generation/src/celeste_image_generation/constraints.py delete mode 100644 packages/capabilities/image-generation/src/celeste_image_generation/io.py delete mode 100644 packages/capabilities/image-generation/src/celeste_image_generation/models.py delete mode 100644 packages/capabilities/image-generation/src/celeste_image_generation/parameters.py delete mode 100644 packages/capabilities/image-generation/src/celeste_image_generation/providers/__init__.py delete mode 100644 packages/capabilities/image-generation/src/celeste_image_generation/providers/bfl/__init__.py delete mode 100644 packages/capabilities/image-generation/src/celeste_image_generation/providers/bfl/client.py delete mode 100644 packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/__init__.py delete mode 100644 packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/client.py delete mode 100644 packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/parameters.py delete mode 100644 packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/streaming.py delete mode 100644 packages/capabilities/image-generation/src/celeste_image_generation/providers/google/__init__.py delete mode 100644 packages/capabilities/image-generation/src/celeste_image_generation/providers/google/client.py delete mode 100644 packages/capabilities/image-generation/src/celeste_image_generation/providers/google/gemini.py delete mode 100644 packages/capabilities/image-generation/src/celeste_image_generation/providers/google/gemini_parameters.py delete mode 100644 packages/capabilities/image-generation/src/celeste_image_generation/providers/google/imagen.py delete mode 100644 packages/capabilities/image-generation/src/celeste_image_generation/providers/google/imagen_parameters.py delete mode 100644 packages/capabilities/image-generation/src/celeste_image_generation/providers/google/parameters.py delete mode 100644 packages/capabilities/image-generation/src/celeste_image_generation/providers/openai/__init__.py delete mode 100644 packages/capabilities/image-generation/src/celeste_image_generation/providers/openai/client.py delete mode 100644 packages/capabilities/image-generation/src/celeste_image_generation/providers/openai/parameters.py delete mode 100644 packages/capabilities/image-generation/src/celeste_image_generation/providers/openai/streaming.py delete mode 100644 packages/capabilities/image-generation/src/celeste_image_generation/streaming.py delete mode 100644 packages/capabilities/image-generation/tests/integration_tests/test_image_generation/__init__.py delete mode 100644 packages/capabilities/image-generation/tests/integration_tests/test_image_generation/test_generate.py delete mode 100644 packages/capabilities/image-generation/tests/unit_tests/providers/google/__init__.py delete mode 100644 packages/capabilities/image-generation/tests/unit_tests/providers/google/test_finish_reason.py delete mode 100644 packages/capabilities/speech-generation/README.md delete mode 100644 packages/capabilities/speech-generation/pyproject.toml delete mode 100644 packages/capabilities/speech-generation/src/celeste_speech_generation/__init__.py delete mode 100644 packages/capabilities/speech-generation/src/celeste_speech_generation/client.py delete mode 100644 packages/capabilities/speech-generation/src/celeste_speech_generation/io.py delete mode 100644 packages/capabilities/speech-generation/src/celeste_speech_generation/models.py delete mode 100644 packages/capabilities/speech-generation/src/celeste_speech_generation/parameters.py delete mode 100644 packages/capabilities/speech-generation/src/celeste_speech_generation/providers/__init__.py delete mode 100644 packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/__init__.py delete mode 100644 packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/client.py delete mode 100644 packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/models.py delete mode 100644 packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/parameters.py delete mode 100644 packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/streaming.py delete mode 100644 packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/__init__.py delete mode 100644 packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/client.py delete mode 100644 packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/mappings.py delete mode 100644 packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/parameters.py delete mode 100644 packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/__init__.py delete mode 100644 packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/client.py delete mode 100644 packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/parameters.py delete mode 100644 packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/__init__.py delete mode 100644 packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/client.py delete mode 100644 packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/models.py delete mode 100644 packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/parameters.py delete mode 100644 packages/capabilities/speech-generation/src/celeste_speech_generation/py.typed delete mode 100644 packages/capabilities/speech-generation/src/celeste_speech_generation/streaming.py delete mode 100644 packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/__init__.py delete mode 100644 packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/test_generate.py delete mode 100644 packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/test_stream.py delete mode 100644 packages/capabilities/text-generation/README.md delete mode 100644 packages/capabilities/text-generation/pyproject.toml delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/__init__.py delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/client.py delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/io.py delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/models.py delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/parameters.py delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/providers/__init__.py delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/providers/anthropic/__init__.py delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/providers/anthropic/client.py delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/providers/anthropic/models.py delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/providers/anthropic/streaming.py delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/__init__.py delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/client.py delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/models.py delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/streaming.py delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/providers/google/__init__.py delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/providers/google/client.py delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/providers/google/models.py delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/providers/google/parameters.py delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/providers/google/streaming.py delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/providers/mistral/__init__.py delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/providers/mistral/client.py delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/providers/mistral/streaming.py delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/__init__.py delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/client.py delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/parameters.py delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/streaming.py delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/__init__.py delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/client.py delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/models.py delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/parameters.py delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/streaming.py delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/py.typed delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/streaming.py delete mode 100644 packages/capabilities/text-generation/tests/integration_tests/conftest.py delete mode 100644 packages/capabilities/text-generation/tests/integration_tests/test_text_generation/__init__.py delete mode 100644 packages/capabilities/text-generation/tests/integration_tests/test_text_generation/test_generate.py delete mode 100644 packages/capabilities/text-generation/tests/integration_tests/test_text_generation/test_stream.py delete mode 100644 packages/capabilities/video-generation/README.md delete mode 100644 packages/capabilities/video-generation/pyproject.toml delete mode 100644 packages/capabilities/video-generation/src/celeste_video_generation/__init__.py delete mode 100644 packages/capabilities/video-generation/src/celeste_video_generation/client.py delete mode 100644 packages/capabilities/video-generation/src/celeste_video_generation/io.py delete mode 100644 packages/capabilities/video-generation/src/celeste_video_generation/models.py delete mode 100644 packages/capabilities/video-generation/src/celeste_video_generation/parameters.py delete mode 100644 packages/capabilities/video-generation/src/celeste_video_generation/providers/__init__.py delete mode 100644 packages/capabilities/video-generation/src/celeste_video_generation/providers/byteplus/README.md delete mode 100644 packages/capabilities/video-generation/src/celeste_video_generation/providers/byteplus/__init__.py delete mode 100644 packages/capabilities/video-generation/src/celeste_video_generation/providers/byteplus/client.py delete mode 100644 packages/capabilities/video-generation/src/celeste_video_generation/providers/byteplus/parameters.py delete mode 100644 packages/capabilities/video-generation/src/celeste_video_generation/providers/google/__init__.py delete mode 100644 packages/capabilities/video-generation/src/celeste_video_generation/providers/google/client.py delete mode 100644 packages/capabilities/video-generation/src/celeste_video_generation/providers/google/models.py delete mode 100644 packages/capabilities/video-generation/src/celeste_video_generation/providers/google/parameters.py delete mode 100644 packages/capabilities/video-generation/src/celeste_video_generation/providers/openai/__init__.py delete mode 100644 packages/capabilities/video-generation/src/celeste_video_generation/providers/openai/client.py delete mode 100644 packages/capabilities/video-generation/src/celeste_video_generation/providers/openai/models.py delete mode 100644 packages/capabilities/video-generation/src/celeste_video_generation/providers/openai/parameters.py delete mode 100644 packages/capabilities/video-generation/src/celeste_video_generation/py.typed delete mode 100644 packages/capabilities/video-generation/tests/integration_tests/test_video_generation/__init__.py delete mode 100644 packages/capabilities/video-generation/tests/integration_tests/test_video_generation/test_generate.py delete mode 100644 packages/providers/anthropic/pyproject.toml delete mode 100644 packages/providers/anthropic/src/celeste_anthropic/__init__.py delete mode 100644 packages/providers/anthropic/src/celeste_anthropic/messages/streaming.py delete mode 100644 packages/providers/bfl/pyproject.toml delete mode 100644 packages/providers/bfl/src/celeste_bfl/__init__.py delete mode 100644 packages/providers/byteplus/pyproject.toml delete mode 100644 packages/providers/byteplus/src/celeste_byteplus/__init__.py delete mode 100644 packages/providers/byteplus/src/celeste_byteplus/images/streaming.py delete mode 100644 packages/providers/cohere/pyproject.toml delete mode 100644 packages/providers/cohere/src/celeste_cohere/__init__.py delete mode 100644 packages/providers/cohere/src/celeste_cohere/chat/streaming.py delete mode 100644 packages/providers/elevenlabs/pyproject.toml delete mode 100644 packages/providers/elevenlabs/src/celeste_elevenlabs/__init__.py delete mode 100644 packages/providers/google/pyproject.toml delete mode 100644 packages/providers/google/src/celeste_google/__init__.py delete mode 100644 packages/providers/google/src/celeste_google/generate_content/streaming.py delete mode 100644 packages/providers/gradium/pyproject.toml delete mode 100644 packages/providers/gradium/src/celeste_gradium/__init__.py delete mode 100644 packages/providers/mistral/pyproject.toml delete mode 100644 packages/providers/mistral/src/celeste_mistral/__init__.py delete mode 100644 packages/providers/mistral/src/celeste_mistral/chat/streaming.py delete mode 100644 packages/providers/openai/pyproject.toml delete mode 100644 packages/providers/openai/src/celeste_openai/__init__.py delete mode 100644 packages/providers/openai/src/celeste_openai/images/streaming.py delete mode 100644 packages/providers/openai/src/celeste_openai/responses/streaming.py delete mode 100644 packages/providers/xai/pyproject.toml delete mode 100644 packages/providers/xai/src/celeste_xai/__init__.py delete mode 100644 packages/providers/xai/src/celeste_xai/responses/streaming.py create mode 100644 src/celeste/modalities/__init__.py create mode 100644 src/celeste/modalities/audio/__init__.py create mode 100644 src/celeste/modalities/audio/client.py rename {packages/capabilities/speech-generation/src/celeste_speech_generation => src/celeste/modalities/audio}/constraints.py (83%) create mode 100644 src/celeste/modalities/audio/io.py rename {packages/capabilities/speech-generation/src/celeste_speech_generation => src/celeste/modalities/audio}/languages.py (100%) create mode 100644 src/celeste/modalities/audio/models.py create mode 100644 src/celeste/modalities/audio/parameters.py create mode 100644 src/celeste/modalities/audio/providers/__init__.py create mode 100644 src/celeste/modalities/audio/providers/elevenlabs/__init__.py create mode 100644 src/celeste/modalities/audio/providers/elevenlabs/client.py create mode 100644 src/celeste/modalities/audio/providers/elevenlabs/models.py create mode 100644 src/celeste/modalities/audio/providers/elevenlabs/parameters.py rename {packages/capabilities/speech-generation/src/celeste_speech_generation => src/celeste/modalities/audio}/providers/elevenlabs/voices.py (98%) create mode 100644 src/celeste/modalities/audio/providers/google/__init__.py create mode 100644 src/celeste/modalities/audio/providers/google/client.py rename {packages/capabilities/speech-generation/src/celeste_speech_generation => src/celeste/modalities/audio}/providers/google/models.py (52%) create mode 100644 src/celeste/modalities/audio/providers/google/parameters.py rename {packages/capabilities/speech-generation/src/celeste_speech_generation => src/celeste/modalities/audio}/providers/google/voices.py (95%) create mode 100644 src/celeste/modalities/audio/providers/gradium/__init__.py create mode 100644 src/celeste/modalities/audio/providers/gradium/client.py rename {packages/capabilities/speech-generation/src/celeste_speech_generation => src/celeste/modalities/audio}/providers/gradium/models.py (54%) create mode 100644 src/celeste/modalities/audio/providers/gradium/parameters.py rename {packages/capabilities/speech-generation/src/celeste_speech_generation => src/celeste/modalities/audio}/providers/gradium/voices.py (93%) create mode 100644 src/celeste/modalities/audio/providers/openai/__init__.py create mode 100644 src/celeste/modalities/audio/providers/openai/client.py create mode 100644 src/celeste/modalities/audio/providers/openai/models.py create mode 100644 src/celeste/modalities/audio/providers/openai/parameters.py rename {packages/capabilities/speech-generation/src/celeste_speech_generation => src/celeste/modalities/audio}/providers/openai/voices.py (95%) create mode 100644 src/celeste/modalities/audio/streaming.py rename {packages/capabilities/speech-generation/src/celeste_speech_generation => src/celeste/modalities/audio}/voices.py (65%) create mode 100644 src/celeste/modalities/embeddings/__init__.py create mode 100644 src/celeste/modalities/embeddings/client.py create mode 100644 src/celeste/modalities/embeddings/io.py create mode 100644 src/celeste/modalities/embeddings/models.py create mode 100644 src/celeste/modalities/embeddings/parameters.py create mode 100644 src/celeste/modalities/embeddings/providers/__init__.py create mode 100644 src/celeste/modalities/embeddings/providers/google/__init__.py create mode 100644 src/celeste/modalities/embeddings/providers/google/client.py create mode 100644 src/celeste/modalities/embeddings/providers/google/models.py create mode 100644 src/celeste/modalities/embeddings/providers/google/parameters.py rename {packages/capabilities/image-generation/src/celeste_image_generation => src/celeste/modalities/embeddings}/py.typed (100%) create mode 100644 src/celeste/modalities/images/__init__.py create mode 100644 src/celeste/modalities/images/client.py create mode 100644 src/celeste/modalities/images/io.py create mode 100644 src/celeste/modalities/images/models.py create mode 100644 src/celeste/modalities/images/parameters.py create mode 100644 src/celeste/modalities/images/providers/__init__.py create mode 100644 src/celeste/modalities/images/providers/bfl/__init__.py create mode 100644 src/celeste/modalities/images/providers/bfl/client.py rename {packages/capabilities/image-generation/src/celeste_image_generation => src/celeste/modalities/images}/providers/bfl/models.py (61%) rename {packages/capabilities/image-generation/src/celeste_image_generation => src/celeste/modalities/images}/providers/bfl/parameters.py (59%) rename {packages/capabilities/image-generation/src/celeste_image_generation => src/celeste/modalities/images}/providers/byteplus/README.md (100%) create mode 100644 src/celeste/modalities/images/providers/byteplus/__init__.py create mode 100644 src/celeste/modalities/images/providers/byteplus/client.py rename {packages/capabilities/image-generation/src/celeste_image_generation => src/celeste/modalities/images}/providers/byteplus/models.py (68%) create mode 100644 src/celeste/modalities/images/providers/byteplus/parameters.py create mode 100644 src/celeste/modalities/images/providers/google/__init__.py create mode 100644 src/celeste/modalities/images/providers/google/client.py create mode 100644 src/celeste/modalities/images/providers/google/gemini.py create mode 100644 src/celeste/modalities/images/providers/google/imagen.py rename {packages/capabilities/image-generation/src/celeste_image_generation => src/celeste/modalities/images}/providers/google/models.py (60%) create mode 100644 src/celeste/modalities/images/providers/google/parameters.py create mode 100644 src/celeste/modalities/images/providers/openai/__init__.py create mode 100644 src/celeste/modalities/images/providers/openai/client.py rename {packages/capabilities/image-generation/src/celeste_image_generation => src/celeste/modalities/images}/providers/openai/models.py (52%) create mode 100644 src/celeste/modalities/images/providers/openai/parameters.py create mode 100644 src/celeste/modalities/images/streaming.py create mode 100644 src/celeste/modalities/text/__init__.py create mode 100644 src/celeste/modalities/text/client.py create mode 100644 src/celeste/modalities/text/io.py create mode 100644 src/celeste/modalities/text/models.py create mode 100644 src/celeste/modalities/text/parameters.py create mode 100644 src/celeste/modalities/text/providers/__init__.py create mode 100644 src/celeste/modalities/text/providers/anthropic/__init__.py create mode 100644 src/celeste/modalities/text/providers/anthropic/client.py create mode 100644 src/celeste/modalities/text/providers/anthropic/models.py rename {packages/capabilities/text-generation/src/celeste_text_generation => src/celeste/modalities/text}/providers/anthropic/parameters.py (57%) create mode 100644 src/celeste/modalities/text/providers/cohere/__init__.py create mode 100644 src/celeste/modalities/text/providers/cohere/client.py create mode 100644 src/celeste/modalities/text/providers/cohere/models.py rename {packages/capabilities/text-generation/src/celeste_text_generation => src/celeste/modalities/text}/providers/cohere/parameters.py (63%) create mode 100644 src/celeste/modalities/text/providers/deepseek/__init__.py create mode 100644 src/celeste/modalities/text/providers/deepseek/client.py create mode 100644 src/celeste/modalities/text/providers/deepseek/models.py create mode 100644 src/celeste/modalities/text/providers/deepseek/parameters.py create mode 100644 src/celeste/modalities/text/providers/google/__init__.py create mode 100644 src/celeste/modalities/text/providers/google/client.py create mode 100644 src/celeste/modalities/text/providers/google/models.py create mode 100644 src/celeste/modalities/text/providers/google/parameters.py create mode 100644 src/celeste/modalities/text/providers/groq/__init__.py create mode 100644 src/celeste/modalities/text/providers/groq/client.py create mode 100644 src/celeste/modalities/text/providers/groq/models.py create mode 100644 src/celeste/modalities/text/providers/groq/parameters.py create mode 100644 src/celeste/modalities/text/providers/mistral/__init__.py create mode 100644 src/celeste/modalities/text/providers/mistral/client.py rename {packages/capabilities/text-generation/src/celeste_text_generation => src/celeste/modalities/text}/providers/mistral/models.py (60%) rename {packages/capabilities/text-generation/src/celeste_text_generation => src/celeste/modalities/text}/providers/mistral/parameters.py (59%) create mode 100644 src/celeste/modalities/text/providers/moonshot/__init__.py create mode 100644 src/celeste/modalities/text/providers/moonshot/client.py create mode 100644 src/celeste/modalities/text/providers/moonshot/models.py create mode 100644 src/celeste/modalities/text/providers/moonshot/parameters.py create mode 100644 src/celeste/modalities/text/providers/openai/__init__.py create mode 100644 src/celeste/modalities/text/providers/openai/client.py rename {packages/capabilities/text-generation/src/celeste_text_generation => src/celeste/modalities/text}/providers/openai/models.py (54%) create mode 100644 src/celeste/modalities/text/providers/openai/parameters.py create mode 100644 src/celeste/modalities/text/providers/xai/__init__.py create mode 100644 src/celeste/modalities/text/providers/xai/client.py create mode 100644 src/celeste/modalities/text/providers/xai/models.py create mode 100644 src/celeste/modalities/text/providers/xai/parameters.py rename {packages/providers/anthropic/src/celeste_anthropic => src/celeste/modalities/text}/py.typed (100%) create mode 100644 src/celeste/modalities/text/streaming.py create mode 100644 src/celeste/modalities/videos/__init__.py create mode 100644 src/celeste/modalities/videos/client.py create mode 100644 src/celeste/modalities/videos/io.py create mode 100644 src/celeste/modalities/videos/models.py create mode 100644 src/celeste/modalities/videos/parameters.py create mode 100644 src/celeste/modalities/videos/providers/__init__.py create mode 100644 src/celeste/modalities/videos/providers/byteplus/__init__.py create mode 100644 src/celeste/modalities/videos/providers/byteplus/client.py rename {packages/capabilities/video-generation/src/celeste_video_generation => src/celeste/modalities/videos}/providers/byteplus/models.py (53%) create mode 100644 src/celeste/modalities/videos/providers/byteplus/parameters.py create mode 100644 src/celeste/modalities/videos/providers/google/__init__.py create mode 100644 src/celeste/modalities/videos/providers/google/client.py create mode 100644 src/celeste/modalities/videos/providers/google/models.py create mode 100644 src/celeste/modalities/videos/providers/google/parameters.py create mode 100644 src/celeste/modalities/videos/providers/openai/__init__.py create mode 100644 src/celeste/modalities/videos/providers/openai/client.py create mode 100644 src/celeste/modalities/videos/providers/openai/models.py create mode 100644 src/celeste/modalities/videos/providers/openai/parameters.py create mode 100644 src/celeste/namespaces/__init__.py create mode 100644 src/celeste/namespaces/domains.py create mode 100644 src/celeste/providers/__init__.py create mode 100644 src/celeste/providers/anthropic/__init__.py rename {packages/providers/anthropic/src/celeste_anthropic => src/celeste/providers/anthropic}/messages/__init__.py (100%) rename {packages/providers/anthropic/src/celeste_anthropic => src/celeste/providers/anthropic}/messages/client.py (76%) rename {packages/providers/anthropic/src/celeste_anthropic => src/celeste/providers/anthropic}/messages/config.py (96%) rename {packages/providers/anthropic/src/celeste_anthropic => src/celeste/providers/anthropic}/messages/parameters.py (86%) create mode 100644 src/celeste/providers/anthropic/messages/streaming.py rename {packages/providers/bfl/src/celeste_bfl => src/celeste/providers/anthropic}/py.typed (100%) create mode 100644 src/celeste/providers/api_references.md create mode 100644 src/celeste/providers/bfl/__init__.py rename {packages/providers/bfl/src/celeste_bfl => src/celeste/providers/bfl}/images/__init__.py (100%) rename {packages/providers/bfl/src/celeste_bfl => src/celeste/providers/bfl}/images/client.py (74%) rename {packages/providers/bfl/src/celeste_bfl => src/celeste/providers/bfl}/images/config.py (88%) rename {packages/providers/bfl/src/celeste_bfl => src/celeste/providers/bfl}/images/parameters.py (100%) rename {packages/providers/bfl/src/celeste_bfl => src/celeste/providers/bfl}/images/utils.py (57%) rename {packages/providers/byteplus/src/celeste_byteplus => src/celeste/providers/bfl}/py.typed (100%) create mode 100644 src/celeste/providers/byteplus/__init__.py rename {packages/providers/byteplus/src/celeste_byteplus => src/celeste/providers/byteplus}/images/__init__.py (100%) rename {packages/providers/byteplus/src/celeste_byteplus => src/celeste/providers/byteplus}/images/client.py (74%) rename {packages/providers/byteplus/src/celeste_byteplus => src/celeste/providers/byteplus}/images/config.py (82%) rename {packages/providers/byteplus/src/celeste_byteplus => src/celeste/providers/byteplus}/images/parameters.py (100%) create mode 100644 src/celeste/providers/byteplus/images/streaming.py rename {packages/providers/cohere/src/celeste_cohere => src/celeste/providers/byteplus}/py.typed (100%) rename {packages/providers/byteplus/src/celeste_byteplus => src/celeste/providers/byteplus}/videos/__init__.py (100%) rename {packages/providers/byteplus/src/celeste_byteplus => src/celeste/providers/byteplus}/videos/client.py (65%) rename {packages/providers/byteplus/src/celeste_byteplus => src/celeste/providers/byteplus}/videos/config.py (91%) rename {packages/providers/byteplus/src/celeste_byteplus => src/celeste/providers/byteplus}/videos/parameters.py (100%) create mode 100644 src/celeste/providers/cohere/__init__.py rename {packages/providers/cohere/src/celeste_cohere => src/celeste/providers/cohere}/chat/__init__.py (100%) rename {packages/providers/cohere/src/celeste_cohere => src/celeste/providers/cohere}/chat/client.py (76%) rename {packages/providers/cohere/src/celeste_cohere => src/celeste/providers/cohere}/chat/config.py (85%) rename {packages/providers/cohere/src/celeste_cohere => src/celeste/providers/cohere}/chat/parameters.py (93%) create mode 100644 src/celeste/providers/cohere/chat/streaming.py rename {packages/providers/elevenlabs/src/celeste_elevenlabs => src/celeste/providers/cohere}/py.typed (100%) create mode 100644 src/celeste/providers/deepseek/__init__.py create mode 100644 src/celeste/providers/deepseek/chat/__init__.py create mode 100644 src/celeste/providers/deepseek/chat/client.py create mode 100644 src/celeste/providers/deepseek/chat/config.py create mode 100644 src/celeste/providers/deepseek/chat/parameters.py create mode 100644 src/celeste/providers/deepseek/chat/streaming.py rename {packages/providers/google/src/celeste_google => src/celeste/providers/deepseek}/py.typed (100%) create mode 100644 src/celeste/providers/elevenlabs/__init__.py rename {packages/providers/gradium/src/celeste_gradium => src/celeste/providers/elevenlabs}/py.typed (100%) rename {packages/providers/elevenlabs/src/celeste_elevenlabs => src/celeste/providers/elevenlabs}/text_to_speech/__init__.py (100%) rename {packages/providers/elevenlabs/src/celeste_elevenlabs => src/celeste/providers/elevenlabs}/text_to_speech/client.py (75%) rename {packages/providers/elevenlabs/src/celeste_elevenlabs => src/celeste/providers/elevenlabs}/text_to_speech/config.py (88%) rename {packages/providers/elevenlabs/src/celeste_elevenlabs => src/celeste/providers/elevenlabs}/text_to_speech/parameters.py (97%) create mode 100644 src/celeste/providers/elevenlabs/text_to_speech/streaming.py create mode 100644 src/celeste/providers/google/__init__.py rename {packages/providers/google/src/celeste_google => src/celeste/providers/google}/auth.py (100%) rename {packages/providers/google/src/celeste_google => src/celeste/providers/google}/cloud_tts/__init__.py (100%) rename {packages/providers/google/src/celeste_google => src/celeste/providers/google}/cloud_tts/client.py (57%) rename {packages/providers/google/src/celeste_google => src/celeste/providers/google}/cloud_tts/config.py (91%) rename {packages/providers/google/src/celeste_google => src/celeste/providers/google}/cloud_tts/parameters.py (100%) create mode 100644 src/celeste/providers/google/embeddings/__init__.py create mode 100644 src/celeste/providers/google/embeddings/client.py create mode 100644 src/celeste/providers/google/embeddings/config.py create mode 100644 src/celeste/providers/google/embeddings/parameters.py rename {packages/providers/google/src/celeste_google => src/celeste/providers/google}/generate_content/__init__.py (100%) rename {packages/providers/google/src/celeste_google => src/celeste/providers/google}/generate_content/client.py (84%) rename {packages/providers/google/src/celeste_google => src/celeste/providers/google}/generate_content/config.py (94%) rename {packages/providers/google/src/celeste_google => src/celeste/providers/google}/generate_content/parameters.py (89%) create mode 100644 src/celeste/providers/google/generate_content/streaming.py rename {packages/providers/google/src/celeste_google => src/celeste/providers/google}/imagen/__init__.py (100%) rename {packages/providers/google/src/celeste_google => src/celeste/providers/google}/imagen/client.py (76%) rename {packages/providers/google/src/celeste_google => src/celeste/providers/google}/imagen/config.py (83%) rename {packages/providers/google/src/celeste_google => src/celeste/providers/google}/imagen/parameters.py (100%) create mode 100644 src/celeste/providers/google/interactions/__init__.py create mode 100644 src/celeste/providers/google/interactions/client.py create mode 100644 src/celeste/providers/google/interactions/config.py create mode 100644 src/celeste/providers/google/interactions/parameters.py create mode 100644 src/celeste/providers/google/interactions/streaming.py rename {packages/providers/mistral/src/celeste_mistral => src/celeste/providers/google}/py.typed (100%) rename {packages/providers/google/src/celeste_google => src/celeste/providers/google}/veo/__init__.py (100%) rename {packages/providers/google/src/celeste_google => src/celeste/providers/google}/veo/client.py (76%) rename {packages/providers/google/src/celeste_google => src/celeste/providers/google}/veo/config.py (92%) rename {packages/providers/google/src/celeste_google => src/celeste/providers/google}/veo/parameters.py (67%) create mode 100644 src/celeste/providers/gradium/__init__.py rename {packages/providers/openai/src/celeste_openai => src/celeste/providers/gradium}/py.typed (100%) rename {packages/providers/gradium/src/celeste_gradium => src/celeste/providers/gradium}/text_to_speech/__init__.py (100%) rename {packages/providers/gradium/src/celeste_gradium => src/celeste/providers/gradium}/text_to_speech/client.py (59%) rename {packages/providers/gradium/src/celeste_gradium => src/celeste/providers/gradium}/text_to_speech/config.py (84%) rename {packages/providers/gradium/src/celeste_gradium => src/celeste/providers/gradium}/text_to_speech/parameters.py (97%) create mode 100644 src/celeste/providers/gradium/text_to_speech/streaming.py create mode 100644 src/celeste/providers/groq/__init__.py create mode 100644 src/celeste/providers/groq/chat/__init__.py create mode 100644 src/celeste/providers/groq/chat/client.py create mode 100644 src/celeste/providers/groq/chat/config.py create mode 100644 src/celeste/providers/groq/chat/parameters.py create mode 100644 src/celeste/providers/groq/chat/streaming.py rename {packages/providers/xai/src/celeste_xai => src/celeste/providers/groq}/py.typed (100%) create mode 100644 src/celeste/providers/mistral/__init__.py rename {packages/providers/mistral/src/celeste_mistral => src/celeste/providers/mistral}/chat/__init__.py (100%) rename {packages/providers/mistral/src/celeste_mistral => src/celeste/providers/mistral}/chat/client.py (65%) rename {packages/providers/mistral/src/celeste_mistral => src/celeste/providers/mistral}/chat/config.py (97%) rename {packages/providers/mistral/src/celeste_mistral => src/celeste/providers/mistral}/chat/parameters.py (79%) create mode 100644 src/celeste/providers/mistral/chat/streaming.py rename packages/capabilities/speech-generation/tests/integration_tests/__init__.py => src/celeste/providers/mistral/py.typed (100%) create mode 100644 src/celeste/providers/moonshot/__init__.py create mode 100644 src/celeste/providers/moonshot/chat/__init__.py create mode 100644 src/celeste/providers/moonshot/chat/client.py create mode 100644 src/celeste/providers/moonshot/chat/config.py create mode 100644 src/celeste/providers/moonshot/chat/parameters.py create mode 100644 src/celeste/providers/moonshot/chat/streaming.py rename src/{__init__.py => celeste/providers/moonshot/py.typed} (100%) create mode 100644 src/celeste/providers/openai/__init__.py rename {packages/providers/openai/src/celeste_openai => src/celeste/providers/openai}/audio/__init__.py (100%) rename {packages/providers/openai/src/celeste_openai => src/celeste/providers/openai}/audio/client.py (51%) rename {packages/providers/openai/src/celeste_openai => src/celeste/providers/openai}/audio/config.py (87%) rename {packages/providers/openai/src/celeste_openai => src/celeste/providers/openai}/audio/parameters.py (100%) rename {packages/providers/openai/src/celeste_openai => src/celeste/providers/openai}/images/__init__.py (100%) rename {packages/providers/openai/src/celeste_openai => src/celeste/providers/openai}/images/client.py (53%) rename {packages/providers/openai/src/celeste_openai => src/celeste/providers/openai}/images/config.py (86%) rename {packages/providers/openai/src/celeste_openai => src/celeste/providers/openai}/images/parameters.py (100%) create mode 100644 src/celeste/providers/openai/images/streaming.py create mode 100644 src/celeste/providers/openai/py.typed rename {packages/providers/openai/src/celeste_openai => src/celeste/providers/openai}/responses/__init__.py (100%) rename {packages/providers/openai/src/celeste_openai => src/celeste/providers/openai}/responses/client.py (79%) rename {packages/providers/openai/src/celeste_openai => src/celeste/providers/openai}/responses/config.py (85%) rename {packages/providers/openai/src/celeste_openai => src/celeste/providers/openai}/responses/parameters.py (93%) create mode 100644 src/celeste/providers/openai/responses/streaming.py rename {packages/providers/openai/src/celeste_openai => src/celeste/providers/openai}/videos/__init__.py (100%) rename {packages/providers/openai/src/celeste_openai => src/celeste/providers/openai}/videos/client.py (78%) rename {packages/providers/openai/src/celeste_openai => src/celeste/providers/openai}/videos/config.py (89%) rename {packages/providers/openai/src/celeste_openai => src/celeste/providers/openai}/videos/parameters.py (79%) create mode 100644 src/celeste/providers/xai/__init__.py create mode 100644 src/celeste/providers/xai/py.typed rename {packages/providers/xai/src/celeste_xai => src/celeste/providers/xai}/responses/__init__.py (100%) rename {packages/providers/xai/src/celeste_xai => src/celeste/providers/xai}/responses/client.py (73%) rename {packages/providers/xai/src/celeste_xai => src/celeste/providers/xai}/responses/config.py (85%) rename {packages/providers/xai/src/celeste_xai => src/celeste/providers/xai}/responses/parameters.py (95%) create mode 100644 src/celeste/providers/xai/responses/streaming.py delete mode 100644 src/celeste/registry.py delete mode 100644 src/celeste/utils.py create mode 100644 src/celeste/utils/__init__.py create mode 100644 src/celeste/utils/image.py create mode 100644 src/celeste/utils/mime.py create mode 100644 templates/modalities/{modality_slug}/src/celeste_{modality_slug}/__init__.py.template create mode 100644 templates/modalities/{modality_slug}/src/celeste_{modality_slug}/client.py.template create mode 100644 templates/modalities/{modality_slug}/src/celeste_{modality_slug}/io.py.template create mode 100644 templates/modalities/{modality_slug}/src/celeste_{modality_slug}/parameters.py.template create mode 100644 templates/modalities/{modality_slug}/src/celeste_{modality_slug}/providers/__init__.py.template create mode 100644 templates/modalities/{modality_slug}/src/celeste_{modality_slug}/providers/{provider_slug}/__init__.py.template create mode 100644 templates/modalities/{modality_slug}/src/celeste_{modality_slug}/providers/{provider_slug}/client.py.template create mode 100644 templates/modalities/{modality_slug}/src/celeste_{modality_slug}/providers/{provider_slug}/models.py.template create mode 100644 templates/modalities/{modality_slug}/src/celeste_{modality_slug}/providers/{provider_slug}/parameters.py.template create mode 100644 templates/modalities/{modality_slug}/src/celeste_{modality_slug}/streaming.py.template create mode 100644 templates/modalities/{modality_slug}/tests/__init__.py.template create mode 100644 templates/modalities/{modality_slug}/tests/integration_tests/__init__.py.template create mode 100644 templates/modalities/{modality_slug}/tests/integration_tests/conftest.py.template create mode 100644 templates/modalities/{modality_slug}/tests/integration_tests/test_{modality_slug}/__init__.py.template create mode 100644 templates/modalities/{modality_slug}/tests/integration_tests/test_{modality_slug}/test_stream_{operation_slug}.py.template create mode 100644 templates/modalities/{modality_slug}/tests/integration_tests/test_{modality_slug}/test_{operation_slug}.py.template create mode 100644 templates/providers/{provider_slug}/src/celeste_{provider_slug}/__init__.py.template create mode 100644 templates/providers/{provider_slug}/src/celeste_{provider_slug}/py.typed.template create mode 100644 templates/providers/{provider_slug}/src/celeste_{provider_slug}/{api_slug}/__init__.py.template create mode 100644 templates/providers/{provider_slug}/src/celeste_{provider_slug}/{api_slug}/client.py.template create mode 100644 templates/providers/{provider_slug}/src/celeste_{provider_slug}/{api_slug}/config.py.template create mode 100644 templates/providers/{provider_slug}/src/celeste_{provider_slug}/{api_slug}/parameters.py.template create mode 100644 templates/providers/{provider_slug}/src/celeste_{provider_slug}/{api_slug}/streaming.py.template create mode 100644 tests/__init__.py create mode 100644 tests/integration_tests/__init__.py create mode 100644 tests/integration_tests/audio/__init__.py create mode 100644 tests/integration_tests/audio/test_speak.py create mode 100644 tests/integration_tests/audio/test_stream_speak.py rename {packages/capabilities/speech-generation/tests => tests}/integration_tests/conftest.py (100%) create mode 100644 tests/integration_tests/embeddings/__init__.py create mode 100644 tests/integration_tests/embeddings/test_embed.py create mode 100644 tests/integration_tests/images/__init__.py create mode 100644 tests/integration_tests/images/assets/square.png create mode 100644 tests/integration_tests/images/conftest.py create mode 100644 tests/integration_tests/images/test_edit.py create mode 100644 tests/integration_tests/images/test_generate.py create mode 100644 tests/integration_tests/images/test_stream_edit.py create mode 100644 tests/integration_tests/images/test_stream_generate.py create mode 100644 tests/integration_tests/text/__init__.py create mode 100644 tests/integration_tests/text/assets/square.png create mode 100644 tests/integration_tests/text/assets/test_audio.mp3 create mode 100644 tests/integration_tests/text/assets/test_video.mp4 create mode 100644 tests/integration_tests/text/conftest.py create mode 100644 tests/integration_tests/text/test_analyze_audio.py create mode 100644 tests/integration_tests/text/test_analyze_image.py create mode 100644 tests/integration_tests/text/test_analyze_video.py create mode 100644 tests/integration_tests/text/test_generate.py create mode 100644 tests/integration_tests/text/test_stream_analyze_audio.py create mode 100644 tests/integration_tests/text/test_stream_analyze_image.py create mode 100644 tests/integration_tests/text/test_stream_analyze_video.py create mode 100644 tests/integration_tests/text/test_stream_generate.py create mode 100644 tests/integration_tests/videos/__init__.py create mode 100644 tests/integration_tests/videos/test_generate.py create mode 100644 tests/testing_guidelines.md create mode 100644 tests/unit_tests/__init__.py create mode 100644 tests/unit_tests/test_auth.py create mode 100644 tests/unit_tests/test_io.py create mode 100644 tests/unit_tests/test_provider_api_templates.py create mode 100644 tests/unit_tests/test_stream_metadata_from_response_data.py create mode 100644 tests/unit_tests/test_text_media_support_validation.py create mode 100644 tests/unit_tests/test_text_modality_analyze_image.py create mode 100644 tests/unit_tests/utils/__init__.py create mode 100644 tests/unit_tests/utils/test_image.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77bb453b..ad0caba2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,12 +30,7 @@ jobs: - uses: ./.github/actions/setup-python-uv with: python-version: ${{ inputs.python-version || '3.12' }} - - run: | - if [ -d "packages" ]; then - uv run ruff format --check src/celeste tests/ packages/ - else - uv run ruff format --check src/celeste tests/ - fi + - run: uv run ruff format --check src/celeste tests/ lint: runs-on: ubuntu-latest @@ -46,12 +41,7 @@ jobs: - uses: ./.github/actions/setup-python-uv with: python-version: ${{ inputs.python-version || '3.12' }} - - run: | - if [ -d "packages" ]; then - uv run ruff check --output-format=github src/celeste tests/ packages/ - else - uv run ruff check --output-format=github src/celeste tests/ - fi + - run: uv run ruff check --output-format=github src/celeste tests/ type-check: runs-on: ubuntu-latest @@ -62,12 +52,7 @@ jobs: - uses: ./.github/actions/setup-python-uv with: python-version: ${{ inputs.python-version || '3.12' }} - - run: | - if [ -d "packages" ]; then - uv run mypy -p celeste && uv run mypy tests/ && uv run mypy packages/capabilities/image-generation packages/capabilities/text-generation packages/capabilities/video-generation packages/capabilities/speech-generation - else - uv run mypy -p celeste && uv run mypy tests/ - fi + - run: uv run mypy -p celeste && uv run mypy tests/ security: runs-on: ubuntu-latest @@ -78,12 +63,7 @@ jobs: - uses: ./.github/actions/setup-python-uv with: python-version: ${{ inputs.python-version || '3.12' }} - - run: | - if [ -d "packages" ]; then - uv run bandit -c pyproject.toml -r src/ packages/ -f screen - else - uv run bandit -c pyproject.toml -r src/ -f screen - fi + - run: uv run bandit -c pyproject.toml -r src/ -f screen test: if: ${{ !inputs.skip-tests }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b6f02b59..bd0579aa 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -41,84 +41,8 @@ jobs: uses: ./.github/workflows/ci.yml secrets: inherit - detect-changes: - needs: run-ci - runs-on: ubuntu-latest - outputs: - packages: ${{ steps.build-matrix.outputs.packages }} - has-changes: ${{ steps.build-matrix.outputs.has-changes }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - id: get-base - run: | - # Get previous tag to compare against (tag pushes don't have github.event.before) - PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") - if [ -z "$PREVIOUS_TAG" ]; then - echo "base=" >> $GITHUB_OUTPUT - echo "run-all=true" >> $GITHUB_OUTPUT - else - echo "base=$PREVIOUS_TAG" >> $GITHUB_OUTPUT - echo "run-all=false" >> $GITHUB_OUTPUT - fi - - uses: dorny/paths-filter@v3 - id: filter - if: steps.get-base.outputs.run-all != 'true' - with: - base: ${{ steps.get-base.outputs.base }} - filters: | - text-generation: - - 'packages/capabilities/text-generation/**' - - '!**/pyproject.toml' - image-generation: - - 'packages/capabilities/image-generation/**' - - '!**/pyproject.toml' - video-generation: - - 'packages/capabilities/video-generation/**' - - '!**/pyproject.toml' - speech-generation: - - 'packages/capabilities/speech-generation/**' - - '!**/pyproject.toml' - core: - - 'src/celeste/**' - - '!**/pyproject.toml' - - id: build-matrix - run: | - PACKAGES=() - - # If first release or paths-filter was skipped, run all - if [[ "${{ steps.get-base.outputs.run-all }}" == "true" ]]; then - PACKAGES=("text-generation" "image-generation" "video-generation" "speech-generation") - else - CORE="${{ steps.filter.outputs.core }}" - if [[ "$CORE" == "true" || "${{ steps.filter.outputs.text-generation }}" == "true" ]]; then - PACKAGES+=("text-generation") - fi - if [[ "$CORE" == "true" || "${{ steps.filter.outputs.image-generation }}" == "true" ]]; then - PACKAGES+=("image-generation") - fi - if [[ "$CORE" == "true" || "${{ steps.filter.outputs.video-generation }}" == "true" ]]; then - PACKAGES+=("video-generation") - fi - if [[ "$CORE" == "true" || "${{ steps.filter.outputs.speech-generation }}" == "true" ]]; then - PACKAGES+=("speech-generation") - fi - fi - - # Convert to JSON array - JSON=$(printf '%s\n' "${PACKAGES[@]}" | jq -R . | jq -s -c .) - echo "packages=$JSON" >> $GITHUB_OUTPUT - - if [[ ${#PACKAGES[@]} -gt 0 ]]; then - echo "has-changes=true" >> $GITHUB_OUTPUT - else - echo "has-changes=false" >> $GITHUB_OUTPUT - fi - integration-tests: - needs: [validate-release, run-ci, detect-changes] - if: needs.detect-changes.outputs.has-changes == 'true' + needs: [validate-release, run-ci] runs-on: ubuntu-latest timeout-minutes: 30 environment: @@ -126,10 +50,6 @@ jobs: permissions: contents: read id-token: write - strategy: - fail-fast: false - matrix: - package: ${{ fromJSON(needs.detect-changes.outputs.packages) }} steps: - uses: actions/checkout@v4 - id: auth @@ -150,10 +70,10 @@ jobs: XAI_API_KEY: ${{ secrets.XAI_API_KEY }} ELEVENLABS_API_KEY: ${{ secrets.ELEVENLABS_API_KEY }} GRADIUM_API_KEY: ${{ secrets.GRADIUM_API_KEY }} - run: uv run pytest packages/capabilities/${{ matrix.package }}/tests/integration_tests/ -m integration -v + run: uv run pytest tests/integration_tests -m integration -v --dist=worksteal -n auto build: - needs: [validate-release, run-ci, detect-changes, integration-tests] + needs: [validate-release, run-ci, integration-tests] if: | always() && needs.validate-release.result == 'success' && @@ -165,7 +85,7 @@ jobs: with: fetch-depth: 1 - uses: ./.github/actions/setup-python-uv - - run: uv build --all-packages + - run: uv build - run: | uv pip install twine uv run twine check dist/* diff --git a/.gitignore b/.gitignore index eb7dd786..c8fccfd6 100644 --- a/.gitignore +++ b/.gitignore @@ -157,9 +157,5 @@ uv.lock # Security reports bandit-report.json -mureka.md -# Temporary/audit files -CHANGELOG_SINCE_RELEASE.md -MIGRATION_AUDIT_REPORT.md scripts/ diff --git a/CHANGELOG_V1.md b/CHANGELOG_V1.md new file mode 100644 index 00000000..11faf9b0 --- /dev/null +++ b/CHANGELOG_V1.md @@ -0,0 +1,154 @@ +# Celeste Python — Recent Diff Summary +Date: 2026-01-15 + +## Scope +- Source: `git diff` (working tree vs last commit) in `celeste-python` +- Files changed: 279 + +## Packaging Rationale (Monolith) +- DX first: pip install celeste-ai is the only thing most users want to remember. Requiring extras like celeste-ai[text] adds cognitive load and drops adoption. +- Lightweight anyway: importing modalities/providers doesn’t add “weight” in practice. The package stays tiny (under ~2 MB), and 90% of the code is core no matter what. +- No heavyweight vendor SDKs: Celeste uses lightweight HTTP clients, so “bundling providers” doesn’t drag in massive dependencies. +- Faster + cleaner: removing the registry/entry‑points means fewer indirections, no dynamic discovery, and faster startup with clearer behavior. +- Simpler maintenance: one package, one version, one release pipeline. Fewer moving parts and fewer “what do I install?” support tickets. +- Predictable behavior: providers are always available; no hidden runtime failures because a plugin wasn’t installed. +- Better UX in docs: one install step + one API surface; examples “just work.” + +## Packaging Changes +- Removed the legacy multi-package workspace and extras-only install paths. +- Now ships as a single PyPI package (`celeste-ai`). +- Updated dependency and tooling configuration to the monolith layout (tests/coverage/mypy/ruff paths now target `src` and `tests` only). +- File updated: `pyproject.toml`. +- File updated: `Makefile`. +- Files deleted: `packages/` (all legacy capability/provider packages and their tests). + +## Tests +- Added modality-first integration tests (`tests/integration_tests/**`) covering generate/edit/analyze/speak and streaming flows per modality. +- Added media fixtures for image/audio/video analysis in integration tests. +- Expanded unit tests for namespace routing, modality inference, stream metadata, auth/credentials registry, artifact handling, and IO validation. +- Replaced legacy package-based tests with a unified monolith test layout. +- Files added: `tests/integration_tests/**`, `tests/testing_guidelines.md`, `tests/unit_tests/utils/**`, `tests/__init__.py`. +- Files updated: `tests/unit_tests/*.py`. +- Files removed: legacy package-specific test suites under `packages/**`. + +## CI/CD +- CI workflow now targets monolith paths only (`src/celeste`, `tests`) for ruff, mypy, and bandit. +- Publish workflow runs required integration tests from `tests/integration_tests` and no longer uses package-based change detection. +- Build step produces a single package (`uv build`) before TestPyPI → PyPI → GitHub release. +- Files updated: `.github/workflows/ci.yml`, `.github/workflows/publish.yml`. + +## Feature Highlight: `extra_body` passthrough +- New `extra_body` parameter lets you pass provider-specific fields or arbitrary JSON into the request body. +- This unblocks new provider capabilities before Celeste adds first-class parameter mapping. +- Implemented via deep-merge into the built request payload. +- File updated: `src/celeste/client.py`. + +## Architecture Shift: Modalities +- v1 moves from capability-centric clients (one method) to modality-centric clients (one output type). Modality = output type (text/images/audio/video/embeddings), operations become methods (`generate`, `edit`, `analyze`, `embed`). +- Domain is the resource you work with (e.g., videos), regardless of whether the input is text or media; modality is what comes out (output type). This clarifies why one client can expose multiple operations while still being type-safe. +- Namespaces are domain-first (the resource you work with); `create_client` is modality-first (the output type you want). Routing uses (domain, operation) → modality. +- Cross-domain operations are explicit: image/audio/video analysis always routes to the text modality; embeddings always route to the embeddings modality. +- Operations exposed: text `generate`/`embed`, images `generate`/`edit`/`analyze`, audio `speak`/`analyze`, videos `generate`/`analyze`. +- Files added: `src/celeste/modalities/` (text/images/audio/videos/embeddings submodules). + +## Namespace API +- Domain namespaces (`celeste.text`, `celeste.images`, `celeste.audio`, `celeste.videos`) provide one-line calls that route to the correct modality/operation under the hood. +- Domain indicates the resource you work with, even if the input is different (e.g., video generation can take text input but stays in the videos domain). Modality indicates the output type (e.g., `celeste.images.analyze(...)` routes to the text modality because analysis returns text). +- The namespace pattern is more discoverable (IDE autocomplete) and matches how people think: start from the domain, then pick the action. +- Execution modes are explicit via namespace properties: async by default, with `.sync` and `.stream` for blocking and streaming workflows. +- The factory pattern remains available for explicit configuration and client reuse when you want full control. +- Files added: `src/celeste/namespaces/__init__.py`, `src/celeste/namespaces/domains.py`. + +## Providers +- Added monolith provider layer under `src/celeste/providers/` with per-provider config/parameters/streaming modules. +- Provider modules include: OpenAI (responses/images/audio/videos), Google (generate_content/imagen/veo/cloud_tts/embeddings/interactions), Anthropic (messages), Cohere/Mistral/DeepSeek/Groq/Moonshot (chat), xAI (responses), ElevenLabs/Gradium (text_to_speech), BFL/BytePlus (images + videos). +- Central provider exports in `providers/__init__.py` for import/discovery. +- Added provider API references doc at `src/celeste/providers/api_references.md`. +- Files added: `src/celeste/providers/**` and `src/celeste/providers/__init__.py`. + +## Contributor Templates +- Added code generation templates for contributors to easily add new modalities, providers, or parameters. +- Templates follow the monolithic architecture with proper relative imports and type patterns. +- **Modality templates** (`templates/modalities/`): Full scaffolding for new output types including client, IO types, parameters, streaming, provider implementations, and integration tests. +- **Provider API templates** (`templates/providers/`): Scaffolding for new provider HTTP clients including config, client mixin, streaming, and parameter mappers. +- Templates include: `client.py`, `io.py`, `parameters.py`, `streaming.py`, `models.py`, `config.py`, and test files. +- Placeholder conventions: `{Modality}` (PascalCase), `{modality}` (lowercase), `{Provider}`, `{provider}`, `{Api}`, `{api}`, `{Content}`. +- Files added: `templates/modalities/**`, `templates/providers/**`. + +## API Wiring +- Updated public exports to v1 surface, including modalities, operations, clients, structured outputs, and namespace singletons. +- Added modality/provider client map and auto-registered modality models at import time. +- Added capability-to-(modality, operation) translation layer to keep `capability` supported with a deprecation warning. +- `create_client` now supports modality + operation arguments, infers operations when possible, and resolves models with better modality-aware errors. +- File updated: `src/celeste/__init__.py`. + +## Docs +- Updated Quick Start and multimodal examples to the namespace‑first v1 API. +- Added a Namespace API section and an “Advanced: create_client” section. +- Added “Behavior changes since v0.3.9”. +- Replaced extras installs with a single‑package install (`pip install celeste-ai` / `uv add celeste-ai`). +- Updated the PyPI badge and added the v1 beta callout banner. +- File updated: `README.md`. + +## Release Prep +- Set package version to `0.9.0` for the public v1 beta. +- Updated development status classifier to Beta. +- Removed notebook/scraping-only runtime deps from install requirements (ipykernel, matplotlib, beautifulsoup4). +- File updated: `pyproject.toml`. + + +## Client Core / Modality Architecture (`src/celeste/client.py`) +- Replaced the capability-specific `Client` with a unified `ModalityClient` base class. +- Removed the capability/provider client registry and related lookup helpers. +- Added `modality` as a first-class field and use it for HTTP client selection. +- Added `sync` and `stream` namespace properties for modality clients. +- `_predict` now takes explicit `inputs` plus optional `endpoint` and `extra_body`, and expects provider `_make_request` to return a response data dict (not an `httpx.Response`). +- `_stream` now takes explicit `inputs` and a `stream_class`, passes `client=self`, and supports `extra_body` + `streaming=True`. +- Added `APIMixin._deep_merge` and extended `_build_request` to merge `extra_body` into the request body. +- Removed capability compatibility validation at init time. +- Updated content types to `TextContent` / generic `Content` and adjusted `_transform_output` accordingly. +- Metadata now includes `modality` and the raw response payload. +- File updated: `src/celeste/client.py`. + +## Auth + Credentials (`src/celeste/auth.py`, `src/celeste/credentials.py`) +- Renamed `APIKey` to `AuthHeader` (with `secret`, `header`, `prefix`), keeping `APIKey` as a backwards-compatible alias. +- `get_auth_class` no longer auto-loads providers from entry points; auth types must be registered explicitly. +- Replaced static provider maps with dynamic auth registry (`register_auth`, `get_auth_config`). +- Added credential fields for `MOONSHOT_API_KEY`, `DEEPSEEK_API_KEY`, and `GROQ_API_KEY`. +- `get_auth` now instantiates custom auth classes or builds an `AuthHeader` from the registry. +- `has_credential` returns true for auth-class providers; `list_available_providers` now filters by registry + credentials. +- Files updated: `src/celeste/auth.py`, `src/celeste/credentials.py`. + +## Artifacts + MIME (`src/celeste/artifacts.py`, `src/celeste/mime_types.py`) +- Added `get_bytes()` and `get_base64()` primitives for content access. +- Removed `_default_mime_type` and `to_data_url()` (moved to utilities). +- Added `src/celeste/utils/mime.py` with `detect_mime_type()` and `build_image_data_url()`. +- MIME detection uses `filetype` library (magic bytes). +- Added JSON serialization for `data: bytes` using base64. +- Files updated: `src/celeste/artifacts.py`, `src/celeste/mime_types.py`. +- Files added: `src/celeste/utils/`. + +## HTTP + WebSocket (`src/celeste/http.py`, `src/celeste/websocket.py`) +- Switched shared client registries to use `Modality` instead of `Capability`. +- HTTP client now recreates the `httpx.AsyncClient` if the event loop changes to avoid "Event loop is closed" errors. +- Files updated: `src/celeste/http.py`, `src/celeste/websocket.py`. + +## Streaming (`src/celeste/streaming.py`) +- Added sync iteration support via anyio blocking portals (`__iter__`, `__next__`). +- Added sync context manager support (`__enter__`, `__exit__`) with portal cleanup. +- Added `_build_stream_metadata` hook (default: raw events). +- Stream exhaustion no longer raises `StreamEmptyError` when no chunks were produced. +- Improved cleanup: guard `aclose` during active iteration and suppress close-time runtime errors. +- File updated: `src/celeste/streaming.py`. + +## Exceptions (`src/celeste/exceptions.py`) +- `ModelNotFoundError` now supports modality-based messages. +- `ClientNotFoundError` expanded to include modality + operation. +- Added `ModalityNotFoundError`. +- File updated: `src/celeste/exceptions.py`. + +## Behavior Changes / Notes +- Provider `_make_request` now returns a response data dict; error handling is expected inside provider implementations. +- Empty streams no longer raise `StreamEmptyError` on exhaustion. +- Auth types are no longer auto-loaded from entry points; they must be registered explicitly. +- Related files: `src/celeste/client.py`, `src/celeste/streaming.py`, `src/celeste/artifacts.py`, `src/celeste/auth.py`. diff --git a/Makefile b/Makefile index a412fc19..113f5e82 100644 --- a/Makefile +++ b/Makefile @@ -22,30 +22,28 @@ sync: # Linting lint: - uv run ruff check src/celeste tests/ packages/ + uv run ruff check src/celeste tests/ # Linting with auto-fix lint-fix: - uv run ruff check --fix src/celeste tests/ packages/ + uv run ruff check --fix src/celeste tests/ # Formatting format: - uv run ruff format src/celeste tests/ packages/ + uv run ruff format src/celeste tests/ # Type checking (fail fast on any error) typecheck: - @uv run mypy -p celeste && uv run mypy tests/ && uv run mypy packages/*/*/src/ + @uv run mypy -p celeste && uv run mypy tests/ # Testing test: uv run pytest tests/unit_tests --cov=celeste --cov-report=term-missing --cov-fail-under=80 -v # Integration testing (requires API keys) -# Usage: make integration-test [capability] +# Runs tests from tests/integration_tests/ integration-test: - @cap="$(filter-out $@,$(MAKECMDGOALS))"; \ - if [ -z "$$cap" ]; then cap="*"; fi; \ - uv run pytest packages/capabilities/$$cap/tests/integration_tests/ -m integration -v --dist=worksteal -n auto + uv run pytest tests/integration_tests/ -m integration -v --dist=worksteal -n auto # Catch capability names as no-op targets %: @@ -53,7 +51,7 @@ integration-test: # Security scanning (config reads from pyproject.toml) security: - uv run bandit -c pyproject.toml -r src/ packages/ -f screen + uv run bandit -c pyproject.toml -r src/ -f screen # Full CI/CD pipeline - what GitHub Actions will run ci: diff --git a/README.md b/README.md index a7e9a19c..8e9cf692 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,13 @@ **The primitive layer for multi-modal AI** -All capabilities. All providers. One interface. +All modalities. All providers. One interface. Primitives, not frameworks. [![Python](https://img.shields.io/badge/Python-3.12+-blue?style=for-the-badge)](https://www.python.org/) [![License](https://img.shields.io/badge/License-MIT-yellow?style=for-the-badge)](LICENSE) -[![PyPI](https://img.shields.io/badge/PyPI-celeste--ai-green?style=for-the-badge)](https://pypi.org/project/celeste-ai/) +[![PyPI](https://img.shields.io/pypi/v/celeste-ai?style=for-the-badge)](https://pypi.org/project/celeste-ai/) @@ -28,10 +28,11 @@ Primitives, not frameworks. -# Celeste AI +> 🚀 This is the v1 Beta release. We're validating the new architecture before the stable v1.0 release. Feedback welcome! +# Celeste AI -Type-safe, capability-provider-agnostic primitives . +Type-safe, modality/provider-agnostic primitives. - **Unified Interface:** One API for OpenAI, Anthropic, Gemini, Mistral, and 14+ others. - **True Multi-Modal:** Text, Image, Audio, Video, Embeddings, Search —all first-class citizens. @@ -42,21 +43,20 @@ Type-safe, capability-provider-agnostic primitives . ## 🚀 Quick Start ```python -from celeste import create_client - +import celeste # "We need a catchy slogan for our new eco-friendly sneaker." -client = create_client( - capability="text-generation", - model="gpt-5" +slogan = await celeste.text.generate( + "Write a slogan for an eco-friendly sneaker.", + model="gpt-5", ) -slogan = await client.generate("Write a slogan for an eco-friendly sneaker.") print(slogan.content) ``` ## 🎨 Multimodal example ```python +import celeste from pydantic import BaseModel, Field class ProductCampaign(BaseModel): @@ -65,27 +65,27 @@ class ProductCampaign(BaseModel): # 2. Extract Campaign Assets (Anthropic) # ----------------------------------------------------- -extract_client = create_client(Capability.TEXT_GENERATION, model="claude-opus-4-1") -campaign_output = await extract_client.generate( +campaign_output = await celeste.text.generate( f"Create campaign assets for slogan: {slogan.content}", - output_schema=ProductCampaign + model="claude-opus-4-1", + output_schema=ProductCampaign, ) campaign = campaign_output.content # 3. Generate Ad Visual (Flux) # ----------------------------------------------------- -image_client = create_client(Capability.IMAGE_GENERATION, model="flux-2-flex") -image_output = await image_client.generate( +image_output = await celeste.images.generate( campaign.visual_prompt, + model="flux-2-flex", aspect_ratio="1:1" ) image = image_output.content # 4. Generate Radio Spot (ElevenLabs) # ----------------------------------------------------- -speech_client = create_client(Capability.SPEECH_GENERATION, model="eleven_v3") -speech_output = await speech_client.generate( +speech_output = await celeste.audio.speak( campaign.audio_script, + model="eleven_v3", voice="adam" ) speech = speech_output.content @@ -183,36 +183,89 @@ user = response.parsed ```python # ✅ Celeste Way -from celeste import create_client, Capability +import celeste +response = await celeste.text.generate( + "Extract user info: John is 30", + model=google_model_id, # <--- Choose any model from any provider + output_schema=User, # <--- Unified parameter working across all providers +) +user = response.content # Already parsed as User instance +``` -client = create_client( - Capability.TEXT_GENERATION, - model=google_model_id # <--- Choose any model from any provider +--- +## 🧭 Namespace API (recommended) +Namespaces are domain-first: start from the resource you want to work with (e.g., videos) even if the input is text. Under the hood, Celeste maps (domain, operation) to the output modality (e.g., `celeste.images.analyze(...)` routes to the text modality because analysis returns text). +```python +import celeste + +# Async (default) +result = await celeste.images.analyze( + image=img, + prompt="Describe this image", + model="gpt-4o" ) -response = await client.generate( - prompt="Extract user info: John is 30", - output_schema=User # <--- Unified parameter working across all providers +# Sync +result = celeste.images.sync.analyze( + image=img, + prompt="Describe this image", + model="gpt-4o" ) -user = response.content # Already parsed as User instance + +# Async streaming +async for chunk in celeste.text.stream.generate("Hello", model="gpt-4o"): + print(chunk.content, end="") + +# Sync streaming +for chunk in celeste.text.sync.stream.generate("Hello", model="gpt-4o"): + print(chunk.content, end="") +``` + +--- +## ⚙️ Advanced: create_client +For explicit configuration or client reuse, use `create_client` with modality + operation. This is modality-first: you choose the output type and operation explicitly. + +```python +from celeste import create_client, Modality, Operation + +client = create_client( + modality=Modality.TEXT, + operation=Operation.GENERATE, + model=google_model_id, +) +response = await client.generate("Extract user info: John is 30", output_schema=User) ``` +> `capability` is still supported but deprecated. Prefer `modality` + `operation`. + --- -## 🪶 Install what you need +## 🪶 Install ```bash -uv add "celeste-ai[text-generation]" # Text only -uv add "celeste-ai[image-generation]" # Image generation -uv add "celeste-ai[all]" # Everything +pip install celeste-ai +# or +uv add celeste-ai ``` --- +## 🔁 Behavior changes since v0.3.9 +- Capabilities → modalities + operations. +- Namespace API is now the default entry point. +- `create_client` now uses `modality` + `operation`; `capability` is deprecated. +- `analyze` for image/audio/video routes through the text modality. +- Namespaces are domain-first (resource you work with); `create_client` is modality-first (output type). Domain + operation maps to modality. +- `extra_body` allows provider-specific parameters without first-class mapping. +- Single-package install (no extras). +--- ## 🔧 Type-Safe by Design ```python # Full IDE autocomplete -response = await client.generate( - prompt="Explain AI", +import celeste + +response = await celeste.text.generate( + "Explain AI", + model="gpt-4o-mini", temperature=0.7, # ✅ Validated (0.0-2.0) max_tokens=100, # ✅ Validated (int) ) diff --git a/packages/capabilities/image-generation/README.md b/packages/capabilities/image-generation/README.md deleted file mode 100644 index c0a6ae62..00000000 --- a/packages/capabilities/image-generation/README.md +++ /dev/null @@ -1,79 +0,0 @@ -
- -# Celeste Logo Celeste Image Generation - -**Image Generation capability for Celeste AI** - -[![Python](https://img.shields.io/badge/Python-3.12+-blue?style=for-the-badge)](https://www.python.org/) -[![License](https://img.shields.io/badge/License-MIT-yellow?style=for-the-badge)](../../../LICENSE) - -[Quick Start](#-quick-start) • [Documentation](https://withceleste.ai/docs) • [Request Provider](https://github.com/withceleste/celeste-python/issues/new) - -
- ---- - -## 🚀 Quick Start - -```python -from celeste import create_client, Capability, Provider - -client = create_client( - capability=Capability.IMAGE_GENERATION, - provider=Provider.OPENAI, -) - -response = await client.generate(prompt="A red apple on a white background") -print(response.content) -``` - -**Install:** -```bash -uv add "celeste-ai[image-generation]" -``` - ---- - -## Supported Providers - - -
- -OpenAI -Google -ByteDance - - -**Missing a provider?** [Request it](https://github.com/withceleste/celeste-python/issues/new) – ⚡ **we ship fast**. - -
- ---- - -**Streaming**: ✅ Supported - -**Parameters**: See [API Documentation](https://withceleste.ai/docs/api) for full parameter reference. - ---- - -## 🤝 Contributing - -See [CONTRIBUTING.md](../../CONTRIBUTING.md) for guidelines. - -**Request a provider:** [GitHub Issues](https://github.com/withceleste/celeste-python/issues/new) - ---- - -## 📄 License - -MIT 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/packages/capabilities/image-generation/pyproject.toml b/packages/capabilities/image-generation/pyproject.toml deleted file mode 100644 index 597e5c6a..00000000 --- a/packages/capabilities/image-generation/pyproject.toml +++ /dev/null @@ -1,50 +0,0 @@ -[project] -name = "celeste-image-generation" -version = "0.3.7" -description = "Image generation package for Celeste AI. Unified interface for all providers" -authors = [{name = "Kamilbenkirane", email = "kamil@withceleste.ai"}] -readme = "README.md" -license = {text = "MIT"} -requires-python = ">=3.12" -classifiers = [ - "Development Status :: 3 - Alpha", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT 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", "image-generation", "dall-e", "imagen", "openai", "google", "byteplus"] -dependencies = [ - "celeste-ai>=0.3.3", - "celeste-bfl>=0.3.3", - "celeste-byteplus>=0.3.3", - "celeste-google>=0.3.3", - "celeste-openai>=0.3.3", -] - -[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" - -[tool.uv.sources] -celeste-ai = { workspace = true } -celeste-bfl = { workspace = true } -celeste-byteplus = { workspace = true } -celeste-google = { workspace = true } -celeste-openai = { workspace = true } - -[project.entry-points."celeste.packages"] -image-generation = "celeste_image_generation:register_package" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/celeste_image_generation"] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/__init__.py b/packages/capabilities/image-generation/src/celeste_image_generation/__init__.py deleted file mode 100644 index 517f1ba2..00000000 --- a/packages/capabilities/image-generation/src/celeste_image_generation/__init__.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Celeste image generation capability.""" - - -def register_package() -> None: - """Register image generation package (client, models, and input).""" - from celeste.client import register_client - from celeste.core import Capability - from celeste.io import register_input - from celeste.models import register_models - from celeste_image_generation.io import ImageGenerationInput - from celeste_image_generation.models import MODELS - from celeste_image_generation.providers import PROVIDERS - - for provider, client_class in PROVIDERS: - register_client(Capability.IMAGE_GENERATION, provider, client_class) - - register_models(MODELS, capability=Capability.IMAGE_GENERATION) - register_input(Capability.IMAGE_GENERATION, ImageGenerationInput) - - -from celeste_image_generation.io import ( # noqa: E402 - ImageGenerationChunk, - ImageGenerationFinishReason, - ImageGenerationInput, - ImageGenerationOutput, - ImageGenerationUsage, -) -from celeste_image_generation.streaming import ImageGenerationStream # noqa: E402 - -__all__ = [ - "ImageGenerationChunk", - "ImageGenerationFinishReason", - "ImageGenerationInput", - "ImageGenerationOutput", - "ImageGenerationStream", - "ImageGenerationUsage", - "register_package", -] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/client.py b/packages/capabilities/image-generation/src/celeste_image_generation/client.py deleted file mode 100644 index 5cf89b19..00000000 --- a/packages/capabilities/image-generation/src/celeste_image_generation/client.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Base client for image generation.""" - -from abc import abstractmethod -from typing import Any, Unpack - -import httpx - -from celeste.artifacts import ImageArtifact -from celeste.client import Client -from celeste.exceptions import ValidationError -from celeste_image_generation.io import ( - ImageGenerationFinishReason, - ImageGenerationInput, - ImageGenerationOutput, - ImageGenerationUsage, -) -from celeste_image_generation.parameters import ImageGenerationParameters - - -class ImageGenerationClient( - Client[ImageGenerationInput, ImageGenerationOutput, ImageGenerationParameters] -): - """Client for image generation operations.""" - - @abstractmethod - def _init_request(self, inputs: ImageGenerationInput) -> dict[str, Any]: - """Initialize provider-specific request structure.""" - - @abstractmethod - def _parse_usage(self, response_data: dict[str, Any]) -> ImageGenerationUsage: - """Parse usage information from provider response.""" - - @abstractmethod - def _parse_content( - self, - response_data: dict[str, Any], - **parameters: Unpack[ImageGenerationParameters], - ) -> ImageArtifact | list[ImageArtifact]: - """Parse content from provider response.""" - - @abstractmethod - def _parse_finish_reason( - self, response_data: dict[str, Any] - ) -> ImageGenerationFinishReason: - """Parse finish reason from provider response.""" - - def _create_inputs( - self, *args: str, **parameters: Unpack[ImageGenerationParameters] - ) -> ImageGenerationInput: - """Map positional arguments to Input type.""" - if args: - return ImageGenerationInput(prompt=args[0]) - prompt: str | None = parameters.get("prompt") - if prompt is None: - msg = ( - "prompt is required (either as positional argument or keyword argument)" - ) - raise ValidationError(msg) - return ImageGenerationInput(prompt=prompt) - - @classmethod - def _output_class(cls) -> type[ImageGenerationOutput]: - """Return the Output class for this client.""" - return ImageGenerationOutput - - def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: - """Build metadata dictionary from response data.""" - metadata = super()._build_metadata(response_data) - metadata["raw_response"] = response_data - return metadata - - @abstractmethod - async def _make_request( - self, - request_body: dict[str, Any], - **parameters: Unpack[ImageGenerationParameters], - ) -> httpx.Response: - """Make HTTP request(s) and return response object.""" diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/constraints.py b/packages/capabilities/image-generation/src/celeste_image_generation/constraints.py deleted file mode 100644 index 806ab41f..00000000 --- a/packages/capabilities/image-generation/src/celeste_image_generation/constraints.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Image generation specific constraints.""" - -from celeste.constraints import Constraint -from celeste.exceptions import ConstraintViolationError - - -class Dimensions(Constraint): - """Dimension string constraint with pixel and aspect ratio bounds.""" - - min_pixels: int - max_pixels: int - min_aspect_ratio: float - max_aspect_ratio: float - presets: dict[str, str] | None = None - - def __call__(self, value: str) -> str: - """Validate dimension string against pixel and aspect ratio bounds.""" - if not isinstance(value, str): - msg = f"Must be string, got {type(value).__name__}" - raise ConstraintViolationError(msg) - - # Check if value is a preset key - if self.presets and value in self.presets: - actual_value = self.presets[value] - else: - actual_value = value - - # Parse dimension format "WIDTHxHEIGHT" - parts = actual_value.lower().split("x") - if len(parts) != 2: - msg = f"Invalid dimension format: {actual_value!r}. Expected 'WIDTHxHEIGHT'" - raise ConstraintViolationError(msg) - - # Validate parts are numeric - if not parts[0].isdigit() or not parts[1].isdigit(): - msg = ( - f"Invalid dimension format: {actual_value!r}. " - f"Width and height must be positive integers" - ) - raise ConstraintViolationError(msg) - - width = int(parts[0]) - height = int(parts[1]) - - # Validate dimensions are positive - if width <= 0 or height <= 0: - msg = f"Width and height must be positive, got {width}x{height}" - raise ConstraintViolationError(msg) - - # Validate total pixels - total_pixels = width * height - if not (self.min_pixels <= total_pixels <= self.max_pixels): - msg = ( - f"Total pixels {total_pixels:,} outside valid range " - f"[{self.min_pixels:,}, {self.max_pixels:,}]" - ) - raise ConstraintViolationError(msg) - - # Validate aspect ratio - aspect_ratio = width / height - if not (self.min_aspect_ratio <= aspect_ratio <= self.max_aspect_ratio): - msg = ( - f"Aspect ratio {aspect_ratio:.3f} outside valid range " - f"[{self.min_aspect_ratio:.3f}, {self.max_aspect_ratio:.3f}]" - ) - raise ConstraintViolationError(msg) - - # Return normalized format - return f"{width}x{height}" - - -__all__ = ["Dimensions"] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/io.py b/packages/capabilities/image-generation/src/celeste_image_generation/io.py deleted file mode 100644 index 010d530b..00000000 --- a/packages/capabilities/image-generation/src/celeste_image_generation/io.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Input and output types for image generation.""" - -from pydantic import Field - -from celeste.artifacts import ImageArtifact -from celeste.io import Chunk, FinishReason, Input, Output, Usage - - -class ImageGenerationInput(Input): - """Input for image generation operations.""" - - prompt: str - - -class ImageGenerationFinishReason(FinishReason): - """Image generation finish reason. - - Stores raw provider reason. Providers map their values in implementation. - """ - - reason: str | None = None - message: str | None = None - - -class ImageGenerationUsage(Usage): - """Image generation usage metrics. - - All fields optional since providers vary. - """ - - total_tokens: int | None = None - input_tokens: int | None = None - output_tokens: int | None = None - reasoning_tokens: int | None = None - num_images: int | None = None - billed_units: float | None = None - input_mp: float | None = None - output_mp: float | None = None - - -class ImageGenerationOutput(Output[ImageArtifact | list[ImageArtifact]]): - """Output with ImageArtifact content (single or multiple).""" - - usage: ImageGenerationUsage = Field(default_factory=ImageGenerationUsage) - finish_reason: ImageGenerationFinishReason | None = None - - -class ImageGenerationChunk(Chunk[ImageArtifact]): - """Typed chunk for image generation streaming.""" - - finish_reason: ImageGenerationFinishReason | None = None - usage: ImageGenerationUsage | None = None - - -__all__ = [ - "ImageGenerationChunk", - "ImageGenerationFinishReason", - "ImageGenerationInput", - "ImageGenerationOutput", - "ImageGenerationUsage", -] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/models.py b/packages/capabilities/image-generation/src/celeste_image_generation/models.py deleted file mode 100644 index ba020565..00000000 --- a/packages/capabilities/image-generation/src/celeste_image_generation/models.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Model definitions for image generation.""" - -from celeste import Model -from celeste_image_generation.providers.bfl.models import MODELS as BFL_MODELS -from celeste_image_generation.providers.byteplus.models import ( - MODELS as BYTEPLUS_MODELS, -) -from celeste_image_generation.providers.google.models import MODELS as GOOGLE_MODELS -from celeste_image_generation.providers.openai.models import MODELS as OPENAI_MODELS - -MODELS: list[Model] = [ - *BFL_MODELS, - *BYTEPLUS_MODELS, - *GOOGLE_MODELS, - *OPENAI_MODELS, -] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/parameters.py b/packages/capabilities/image-generation/src/celeste_image_generation/parameters.py deleted file mode 100644 index 3d2574d8..00000000 --- a/packages/capabilities/image-generation/src/celeste_image_generation/parameters.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Parameters for image generation.""" - -from enum import StrEnum - -from celeste.artifacts import ImageArtifact -from celeste.parameters import Parameters - - -class ImageGenerationParameter(StrEnum): - """Unified parameter names for image generation capability.""" - - ASPECT_RATIO = "aspect_ratio" - NUM_IMAGES = "num_images" - PARTIAL_IMAGES = "partial_images" - QUALITY = "quality" - WATERMARK = "watermark" - REFERENCE_IMAGES = "reference_images" - PROMPT_UPSAMPLING = "prompt_upsampling" - SEED = "seed" - SAFETY_TOLERANCE = "safety_tolerance" - OUTPUT_FORMAT = "output_format" - STEPS = "steps" - GUIDANCE = "guidance" - - -class ImageGenerationParameters(Parameters): - """Parameters for image generation.""" - - aspect_ratio: str | None - num_images: int | None - partial_images: int | None - quality: str | None - watermark: bool | None - reference_images: list[ImageArtifact] | None - prompt_upsampling: bool | None - seed: int | None - safety_tolerance: int | None - output_format: str | None - steps: int | None - guidance: float | None diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/__init__.py b/packages/capabilities/image-generation/src/celeste_image_generation/providers/__init__.py deleted file mode 100644 index 7c6549d7..00000000 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/__init__.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Provider implementations for image generation.""" - -from celeste import Client, Provider - -__all__ = ["PROVIDERS"] - - -def _get_providers() -> list[tuple[Provider, type[Client]]]: - """Lazy-load providers.""" - # Import clients directly from .client modules to avoid __init__.py imports - from celeste_image_generation.providers.bfl.client import ( - BFLImageGenerationClient, - ) - from celeste_image_generation.providers.byteplus.client import ( - BytePlusImageGenerationClient, - ) - from celeste_image_generation.providers.google.client import ( - GoogleImageGenerationClient, - ) - from celeste_image_generation.providers.openai.client import ( - OpenAIImageGenerationClient, - ) - - return [ - (Provider.BFL, BFLImageGenerationClient), - (Provider.BYTEPLUS, BytePlusImageGenerationClient), - (Provider.GOOGLE, GoogleImageGenerationClient), - (Provider.OPENAI, OpenAIImageGenerationClient), - ] - - -PROVIDERS: list[tuple[Provider, type[Client]]] = _get_providers() diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/bfl/__init__.py b/packages/capabilities/image-generation/src/celeste_image_generation/providers/bfl/__init__.py deleted file mode 100644 index f82295b9..00000000 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/bfl/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""BFL (Black Forest Labs) provider for image generation.""" - -from .client import BFLImageGenerationClient -from .models import MODELS - -__all__ = ["MODELS", "BFLImageGenerationClient"] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/bfl/client.py b/packages/capabilities/image-generation/src/celeste_image_generation/providers/bfl/client.py deleted file mode 100644 index 45aa3672..00000000 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/bfl/client.py +++ /dev/null @@ -1,67 +0,0 @@ -"""BFL client implementation for image generation.""" - -from typing import Any, Unpack - -from celeste_bfl.images.client import BFLImagesClient - -from celeste.artifacts import ImageArtifact -from celeste.exceptions import ValidationError -from celeste.parameters import ParameterMapper -from celeste_image_generation.client import ImageGenerationClient -from celeste_image_generation.io import ( - ImageGenerationFinishReason, - ImageGenerationInput, - ImageGenerationUsage, -) -from celeste_image_generation.parameters import ImageGenerationParameters - -from .parameters import BFL_PARAMETER_MAPPERS - - -class BFLImageGenerationClient(BFLImagesClient, ImageGenerationClient): - """BFL client for image generation.""" - - @classmethod - def parameter_mappers(cls) -> list[ParameterMapper]: - return BFL_PARAMETER_MAPPERS - - def _init_request(self, inputs: ImageGenerationInput) -> dict[str, Any]: - """Initialize request for BFL API format.""" - return { - "prompt": inputs.prompt, - } - - def _parse_usage(self, response_data: dict[str, Any]) -> ImageGenerationUsage: - """Parse usage from response.""" - usage = super()._parse_usage(response_data) - return ImageGenerationUsage(**usage) - - def _parse_content( - self, - response_data: dict[str, Any], - **parameters: Unpack[ImageGenerationParameters], - ) -> ImageArtifact: - """Parse content from response.""" - result = response_data.get("result", {}) - sample_url = result.get("sample") - - if not sample_url: - msg = f"No image URL in {self.provider} response" - raise ValidationError(msg) - - return ImageArtifact(url=sample_url) - - def _parse_finish_reason( - self, response_data: dict[str, Any] - ) -> ImageGenerationFinishReason: - """Parse finish reason from response.""" - status = response_data.get("status") - if status == "Ready": - return ImageGenerationFinishReason(reason="COMPLETE") - elif status in ("Error", "Failed"): - error_msg = response_data.get("error", "Generation failed") - return ImageGenerationFinishReason(reason="ERROR", message=error_msg) - return ImageGenerationFinishReason(reason=None) - - -__all__ = ["BFLImageGenerationClient"] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/__init__.py b/packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/__init__.py deleted file mode 100644 index 213be409..00000000 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""BytePlus provider for image generation.""" - -from .client import BytePlusImageGenerationClient -from .models import MODELS - -__all__ = ["MODELS", "BytePlusImageGenerationClient"] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/client.py b/packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/client.py deleted file mode 100644 index 2a887df6..00000000 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/client.py +++ /dev/null @@ -1,107 +0,0 @@ -"""BytePlus client implementation for image generation.""" - -import base64 -from typing import Any, Unpack - -from celeste_byteplus.images.client import BytePlusImagesClient - -from celeste.artifacts import ImageArtifact -from celeste.exceptions import ConstraintViolationError, ValidationError -from celeste.mime_types import ImageMimeType -from celeste.parameters import ParameterMapper -from celeste_image_generation.client import ImageGenerationClient -from celeste_image_generation.io import ( - ImageGenerationFinishReason, - ImageGenerationInput, - ImageGenerationUsage, -) -from celeste_image_generation.parameters import ImageGenerationParameters - -from .parameters import BYTEPLUS_PARAMETER_MAPPERS -from .streaming import BytePlusImageGenerationStream - - -class BytePlusImageGenerationClient(BytePlusImagesClient, ImageGenerationClient): - """BytePlus client for image generation.""" - - @classmethod - def parameter_mappers(cls) -> list[ParameterMapper]: - return BYTEPLUS_PARAMETER_MAPPERS - - def _init_request(self, inputs: ImageGenerationInput) -> dict[str, Any]: - """Initialize request from BytePlus API structure.""" - return { - "model": self.model.id, - "prompt": inputs.prompt, - "response_format": "url", - } - - def _parse_usage(self, response_data: dict[str, Any]) -> ImageGenerationUsage: - """Parse usage from response.""" - usage = super()._parse_usage(response_data) - return ImageGenerationUsage(**usage) - - def _parse_content( - self, - response_data: dict[str, Any], - **parameters: Unpack[ImageGenerationParameters], - ) -> ImageArtifact: - """Parse content from response.""" - images = response_data.get("images", []) - if images and images[0].get("url"): - return ImageArtifact( - url=images[0]["url"], - mime_type=ImageMimeType.PNG, - ) - - data = response_data.get("data", []) - if data: - if data[0].get("url"): - return ImageArtifact( - url=data[0]["url"], - mime_type=ImageMimeType.PNG, - ) - if data[0].get("b64_json"): - image_bytes = base64.b64decode(data[0]["b64_json"]) - return ImageArtifact( - data=image_bytes, - mime_type=ImageMimeType.PNG, - ) - - msg = "No image content found in BytePlus response" - raise ValidationError(msg) - - def _parse_finish_reason( - self, response_data: dict[str, Any] - ) -> ImageGenerationFinishReason: - """Parse finish reason from response. - - BytePlus doesn't provide finish reasons for image generation. - """ - return ImageGenerationFinishReason(reason=None) - - async def _make_request( - self, - request_body: dict[str, Any], - **parameters: Unpack[ImageGenerationParameters], - ) -> Any: - """Make HTTP request with parameter validation.""" - # Validate mutually exclusive parameters - if parameters.get("aspect_ratio") and parameters.get("quality"): - msg = ( - "Cannot use both 'aspect_ratio' and 'quality' parameters. " - "BytePlus's 'size' field supports two methods that cannot be combined:\n" - " • quality: Resolution class ('1K', '2K', '4K')\n" - " • aspect_ratio: Exact dimensions (e.g., '2048x2048', '3840x2160')\n" - "Use one or the other, not both." - ) - raise ConstraintViolationError(msg) - - return await super()._make_request(request_body, **parameters) - - def _stream_class(self) -> type[BytePlusImageGenerationStream]: - """Return the Stream class for this client.""" - return BytePlusImageGenerationStream - - -__all__ = ["BytePlusImageGenerationClient"] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/parameters.py b/packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/parameters.py deleted file mode 100644 index 8bc19da7..00000000 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/parameters.py +++ /dev/null @@ -1,57 +0,0 @@ -"""BytePlus Images parameter mappers for image generation.""" - -from typing import Any - -from celeste_byteplus.images.parameters import ( - SizeMapper as _SizeMapper, -) -from celeste_byteplus.images.parameters import ( - WatermarkMapper as _WatermarkMapper, -) - -from celeste.models import Model -from celeste.parameters import ParameterMapper -from celeste_image_generation.parameters import ImageGenerationParameter - - -class AspectRatioMapper(_SizeMapper): - name = ImageGenerationParameter.ASPECT_RATIO - - -class QualityMapper(_SizeMapper): - """Map quality to BytePlus size field with conflict resolution.""" - - name = ImageGenerationParameter.QUALITY - - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Transform quality into provider request. - - Skips if size is already set by aspect_ratio (conflict resolution). - """ - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - # Skip if size already set by aspect_ratio parameter - if "size" in request: - return request - - return super().map(request, validated_value, model) - - -class WatermarkMapper(_WatermarkMapper): - name = ImageGenerationParameter.WATERMARK - - -BYTEPLUS_PARAMETER_MAPPERS: list[ParameterMapper] = [ - AspectRatioMapper(), - QualityMapper(), - WatermarkMapper(), -] - -__all__ = ["BYTEPLUS_PARAMETER_MAPPERS"] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/streaming.py b/packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/streaming.py deleted file mode 100644 index 96575547..00000000 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/streaming.py +++ /dev/null @@ -1,81 +0,0 @@ -"""BytePlus streaming for image generation.""" - -import base64 -import logging -from collections.abc import AsyncIterator -from typing import Any - -from celeste_byteplus.images.streaming import BytePlusImagesStream - -from celeste.artifacts import ImageArtifact -from celeste.core import UsageField -from celeste.mime_types import ImageMimeType -from celeste_image_generation.io import ImageGenerationChunk, ImageGenerationUsage -from celeste_image_generation.streaming import ImageGenerationStream - -logger = logging.getLogger(__name__) - - -class BytePlusImageGenerationStream(BytePlusImagesStream, ImageGenerationStream): - """BytePlus streaming for image generation.""" - - def __init__(self, sse_iterator: AsyncIterator[dict[str, Any]]) -> None: - """Initialize stream and track completed event usage.""" - super().__init__(sse_iterator) - self._completed_usage: ImageGenerationUsage | None = None - - def _parse_chunk(self, chunk_data: dict[str, Any]) -> ImageGenerationChunk | None: - """Parse chunk from SSE event. - - Uses provider mixin to parse raw SSE event, then wraps in typed chunk. - """ - raw = super()._parse_chunk(chunk_data) - if not raw: - return None - - # Handle error events - if raw.get("is_error"): - error = raw.get("error", {}) - logger.error( - "Image generation failed: %s - %s", - error.get("code"), - error.get("message"), - ) - return None - - # Handle completed event (usage only) - usage_data = raw.get("usage") - if usage_data: - self._completed_usage = ImageGenerationUsage( - total_tokens=usage_data.get(UsageField.TOTAL_TOKENS), - output_tokens=usage_data.get(UsageField.OUTPUT_TOKENS), - num_images=usage_data.get(UsageField.NUM_IMAGES), - ) - return None - - # Handle partial succeeded (image content) - content = raw.get("content") - content_type = raw.get("content_type") - if not content: - return None - - if content_type == "url": - artifact = ImageArtifact(url=content, mime_type=ImageMimeType.PNG) - else: # b64_json - image_data = base64.b64decode(content) - artifact = ImageArtifact(data=image_data) - - return ImageGenerationChunk(content=artifact) - - def _parse_usage(self, chunks: list[ImageGenerationChunk]) -> ImageGenerationUsage: - """Parse usage from chunks. - - Usage is stored from the completed event. - """ - if self._completed_usage is not None: - return self._completed_usage - - return ImageGenerationUsage() - - -__all__ = ["BytePlusImageGenerationStream"] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/google/__init__.py b/packages/capabilities/image-generation/src/celeste_image_generation/providers/google/__init__.py deleted file mode 100644 index 642f7c29..00000000 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/google/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Google provider for image generation.""" - -from .client import GoogleImageGenerationClient -from .models import GEMINI_MODELS, IMAGEN_MODELS, MODELS - -__all__ = [ - "GEMINI_MODELS", - "IMAGEN_MODELS", - "MODELS", - "GoogleImageGenerationClient", -] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/google/client.py b/packages/capabilities/image-generation/src/celeste_image_generation/providers/google/client.py deleted file mode 100644 index 8e1b4978..00000000 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/google/client.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Google client implementation for image generation.""" - -from typing import Any, Unpack - -import httpx - -from celeste.artifacts import ImageArtifact -from celeste.parameters import ParameterMapper -from celeste_image_generation.client import ImageGenerationClient -from celeste_image_generation.io import ( - ImageGenerationFinishReason, - ImageGenerationInput, - ImageGenerationUsage, -) -from celeste_image_generation.parameters import ImageGenerationParameters - -from .gemini import GeminiImageGenerationClient -from .imagen import ImagenImageGenerationClient -from .models import GEMINI_MODELS, IMAGEN_MODELS -from .parameters import GOOGLE_PARAMETER_MAPPERS - -# Model ID → Client class mapping (extensible - add new model types here) -GOOGLE_MODEL_MAP = { - **{m.id: ImagenImageGenerationClient for m in IMAGEN_MODELS}, - **{m.id: GeminiImageGenerationClient for m in GEMINI_MODELS}, -} - - -class GoogleImageGenerationClient(ImageGenerationClient): - """Google client for image generation.""" - - _strategy: GeminiImageGenerationClient | ImagenImageGenerationClient | None = None - - def model_post_init(self, __context: object) -> None: - """Initialize strategy based on model.""" - super().model_post_init(__context) - - StrategyClass = GOOGLE_MODEL_MAP[self.model.id] - strategy = StrategyClass( - model=self.model, - provider=self.provider, - capability=self.capability, - auth=self.auth, - ) - object.__setattr__(self, "_strategy", strategy) - - @classmethod - def parameter_mappers(cls) -> list[ParameterMapper]: - return GOOGLE_PARAMETER_MAPPERS - - def _init_request(self, inputs: ImageGenerationInput) -> dict[str, Any]: - """Delegate to strategy.""" - return self._strategy._init_request(inputs) # type: ignore[union-attr] - - def _parse_usage(self, response_data: dict[str, Any]) -> ImageGenerationUsage: - """Delegate to strategy.""" - return self._strategy._parse_usage(response_data) # type: ignore[union-attr] - - def _parse_content( - self, - response_data: dict[str, Any], - **parameters: Unpack[ImageGenerationParameters], - ) -> ImageArtifact | list[ImageArtifact]: - """Delegate to strategy.""" - return self._strategy._parse_content(response_data, **parameters) # type: ignore[union-attr] - - def _parse_finish_reason( - self, response_data: dict[str, Any] - ) -> ImageGenerationFinishReason: - """Delegate to strategy.""" - return self._strategy._parse_finish_reason(response_data) # type: ignore[union-attr] - - async def _make_request( - self, - request_body: dict[str, Any], - **parameters: Unpack[ImageGenerationParameters], - ) -> httpx.Response: - """Delegate to strategy.""" - return await self._strategy._make_request(request_body, **parameters) # type: ignore[union-attr] - - -__all__ = ["GoogleImageGenerationClient"] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/google/gemini.py b/packages/capabilities/image-generation/src/celeste_image_generation/providers/google/gemini.py deleted file mode 100644 index 42a7ee97..00000000 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/google/gemini.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Gemini client for Google image generation.""" - -import base64 -from typing import Any, Unpack - -from celeste_google.generate_content.client import GoogleGenerateContentClient - -from celeste.artifacts import ImageArtifact -from celeste.mime_types import ImageMimeType -from celeste.parameters import ParameterMapper -from celeste_image_generation.client import ImageGenerationClient -from celeste_image_generation.io import ( - ImageGenerationFinishReason, - ImageGenerationInput, - ImageGenerationUsage, -) -from celeste_image_generation.parameters import ImageGenerationParameters - - -class GeminiImageGenerationClient(GoogleGenerateContentClient, ImageGenerationClient): - """Google Gemini client for image generation.""" - - @classmethod - def parameter_mappers(cls) -> list[ParameterMapper]: - """Parameter mappers for Gemini image generation.""" - return [] # Parameter mapping handled by GoogleImageGenerationClient wrapper - - def _init_request(self, inputs: ImageGenerationInput) -> dict[str, Any]: - """Initialize request for Gemini image generation.""" - return { - "contents": [{"parts": [{"text": inputs.prompt}]}], - "generationConfig": { - "responseModalities": ["Image"], - "imageConfig": {}, - }, - } - - def _parse_usage(self, response_data: dict[str, Any]) -> ImageGenerationUsage: - """Parse usage from response.""" - usage = super()._parse_usage(response_data) - candidates = response_data.get("candidates", []) - return ImageGenerationUsage(**usage, num_images=len(candidates)) - - def _parse_content( - self, - response_data: dict[str, Any], - **parameters: Unpack[ImageGenerationParameters], - ) -> ImageArtifact | list[ImageArtifact]: - """Parse content from response.""" - candidates = super()._parse_content(response_data) - artifacts = [] - - for candidate in candidates: - content = candidate.get("content", {}) - parts = content.get("parts", []) - for part in parts: - inline_data = part.get("inlineData", {}) - base64_data = inline_data.get("data") - - if base64_data: - mime_type = ImageMimeType(inline_data.get("mimeType", "image/png")) - image_bytes = base64.b64decode(base64_data) - artifacts.append( - ImageArtifact(data=image_bytes, mime_type=mime_type) - ) - - if not artifacts: - return ImageArtifact() - - if len(artifacts) == 1: - return artifacts[0] - - return artifacts - - def _parse_finish_reason( - self, response_data: dict[str, Any] - ) -> ImageGenerationFinishReason: - """Parse finish reason from response.""" - finish_reason = super()._parse_finish_reason(response_data) - candidates = response_data.get("candidates", []) - finish_message = None - if candidates: - finish_message = candidates[0].get("finishMessage") - return ImageGenerationFinishReason( - reason=finish_reason.reason, - message=finish_message, - ) - - -__all__ = ["GeminiImageGenerationClient"] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/google/gemini_parameters.py b/packages/capabilities/image-generation/src/celeste_image_generation/providers/google/gemini_parameters.py deleted file mode 100644 index 9a9acf5c..00000000 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/google/gemini_parameters.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Google Gemini parameter mappers for image generation.""" - -from celeste_google.generate_content.parameters import ( - AspectRatioMapper as _AspectRatioMapper, -) -from celeste_google.generate_content.parameters import ( - ImageSizeMapper as _ImageSizeMapper, -) -from celeste_google.generate_content.parameters import ( - MediaContentMapper as _MediaContentMapper, -) - -from celeste.parameters import ParameterMapper -from celeste_image_generation.parameters import ImageGenerationParameter - - -class AspectRatioMapper(_AspectRatioMapper): - name = ImageGenerationParameter.ASPECT_RATIO - - -class QualityMapper(_ImageSizeMapper): - name = ImageGenerationParameter.QUALITY - - -class ReferenceImagesMapper(_MediaContentMapper): - name = ImageGenerationParameter.REFERENCE_IMAGES - - -GEMINI_PARAMETER_MAPPERS: list[ParameterMapper] = [ - AspectRatioMapper(), - QualityMapper(), - ReferenceImagesMapper(), -] - -__all__ = ["GEMINI_PARAMETER_MAPPERS"] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/google/imagen.py b/packages/capabilities/image-generation/src/celeste_image_generation/providers/google/imagen.py deleted file mode 100644 index 63a68b2f..00000000 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/google/imagen.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Imagen client for Google image generation.""" - -import base64 -from typing import Any, Unpack - -from celeste_google.imagen.client import GoogleImagenClient - -from celeste.artifacts import ImageArtifact -from celeste.mime_types import ImageMimeType -from celeste.parameters import ParameterMapper -from celeste_image_generation.client import ImageGenerationClient -from celeste_image_generation.io import ( - ImageGenerationFinishReason, - ImageGenerationInput, - ImageGenerationUsage, -) -from celeste_image_generation.parameters import ImageGenerationParameters - -from .imagen_parameters import IMAGEN_PARAMETER_MAPPERS - - -class ImagenImageGenerationClient(GoogleImagenClient, ImageGenerationClient): - """Google Imagen client for image generation. - - Uses Imagen API format: instances[].prompt → predictions[]. - For Imagen models (imagen-3.x, imagen-4.x). - """ - - @classmethod - def parameter_mappers(cls) -> list[ParameterMapper]: - return IMAGEN_PARAMETER_MAPPERS - - def _init_request(self, inputs: ImageGenerationInput) -> dict[str, Any]: - """Initialize request for Imagen API.""" - return { - "instances": [{"prompt": inputs.prompt}], - "parameters": {}, - } - - def _parse_usage(self, response_data: dict[str, Any]) -> ImageGenerationUsage: - """Parse usage from response.""" - usage = super()._parse_usage(response_data) - return ImageGenerationUsage(**usage) - - def _parse_content( - self, - response_data: dict[str, Any], - **parameters: Unpack[ImageGenerationParameters], - ) -> ImageArtifact | list[ImageArtifact]: - """Parse content from response. - - Returns ImageArtifact for single image, list[ImageArtifact] for multiple. - """ - predictions = response_data.get("predictions", []) - if not predictions: - return ImageArtifact() - - images: list[ImageArtifact] = [] - for prediction in predictions: - base64_data = prediction.get("bytesBase64Encoded") - if base64_data: - mime_type = ImageMimeType(prediction.get("mimeType", "image/png")) - image_bytes = base64.b64decode(base64_data) - images.append(ImageArtifact(data=image_bytes, mime_type=mime_type)) - - # Return type logic: - # - num_images=1 explicitly → single ImageArtifact - # - num_images>1 explicitly → list (even if fewer returned) - # - num_images=None (not specified) → based on actual count returned - num_images_requested = parameters.get("num_images") - if num_images_requested == 1: - return images[0] if images else ImageArtifact() - if num_images_requested is not None and num_images_requested > 1: - return images if images else [] - # Not specified: return based on what provider actually returned - if len(images) == 1: - return images[0] - return images if images else ImageArtifact() - - def _parse_finish_reason( - self, response_data: dict[str, Any] - ) -> ImageGenerationFinishReason: - """Parse finish reason from response. - - Imagen API doesn't provide finish reasons. - """ - return ImageGenerationFinishReason(reason=None) - - -__all__ = ["ImagenImageGenerationClient"] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/google/imagen_parameters.py b/packages/capabilities/image-generation/src/celeste_image_generation/providers/google/imagen_parameters.py deleted file mode 100644 index ca06d001..00000000 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/google/imagen_parameters.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Google Imagen parameter mappers for image generation.""" - -from celeste_google.imagen.parameters import ( - AspectRatioMapper as _AspectRatioMapper, -) -from celeste_google.imagen.parameters import ( - ImageSizeMapper as _ImageSizeMapper, -) -from celeste_google.imagen.parameters import ( - SampleCountMapper as _SampleCountMapper, -) - -from celeste.parameters import ParameterMapper -from celeste_image_generation.parameters import ImageGenerationParameter - - -class AspectRatioMapper(_AspectRatioMapper): - name = ImageGenerationParameter.ASPECT_RATIO - - -class QualityMapper(_ImageSizeMapper): - name = ImageGenerationParameter.QUALITY - - -class NumImagesMapper(_SampleCountMapper): - name = ImageGenerationParameter.NUM_IMAGES - - -IMAGEN_PARAMETER_MAPPERS: list[ParameterMapper] = [ - AspectRatioMapper(), - QualityMapper(), - NumImagesMapper(), -] - -__all__ = ["IMAGEN_PARAMETER_MAPPERS"] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/google/parameters.py b/packages/capabilities/image-generation/src/celeste_image_generation/providers/google/parameters.py deleted file mode 100644 index c4e37f1f..00000000 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/google/parameters.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Google Gemini and Imagen parameter mappers for image generation.""" - -from celeste.parameters import ParameterMapper - -from .gemini_parameters import GEMINI_PARAMETER_MAPPERS -from .imagen_parameters import IMAGEN_PARAMETER_MAPPERS - -GOOGLE_PARAMETER_MAPPERS: list[ParameterMapper] = ( - GEMINI_PARAMETER_MAPPERS + IMAGEN_PARAMETER_MAPPERS -) - -__all__ = ["GOOGLE_PARAMETER_MAPPERS"] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/openai/__init__.py b/packages/capabilities/image-generation/src/celeste_image_generation/providers/openai/__init__.py deleted file mode 100644 index 96e62b5a..00000000 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/openai/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""OpenAI provider for image generation.""" - -from .client import OpenAIImageGenerationClient -from .models import MODELS -from .streaming import OpenAIImageGenerationStream - -__all__ = ["MODELS", "OpenAIImageGenerationClient", "OpenAIImageGenerationStream"] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/openai/client.py b/packages/capabilities/image-generation/src/celeste_image_generation/providers/openai/client.py deleted file mode 100644 index bd980b99..00000000 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/openai/client.py +++ /dev/null @@ -1,80 +0,0 @@ -"""OpenAI client implementation for image generation.""" - -import base64 -from typing import Any, Unpack - -from celeste_openai.images.client import OpenAIImagesClient - -from celeste.artifacts import ImageArtifact -from celeste.parameters import ParameterMapper -from celeste_image_generation.client import ImageGenerationClient -from celeste_image_generation.io import ( - ImageGenerationFinishReason, - ImageGenerationInput, - ImageGenerationUsage, -) -from celeste_image_generation.parameters import ImageGenerationParameters - -from .parameters import OPENAI_PARAMETER_MAPPERS -from .streaming import OpenAIImageGenerationStream - - -class OpenAIImageGenerationClient(OpenAIImagesClient, ImageGenerationClient): - """OpenAI client for image generation.""" - - @classmethod - def parameter_mappers(cls) -> list[ParameterMapper]: - return OPENAI_PARAMETER_MAPPERS - - def _init_request(self, inputs: ImageGenerationInput) -> dict[str, Any]: - """Initialize request from OpenAI API format.""" - request = { - "model": self.model.id, - "prompt": inputs.prompt, - "n": 1, - } - - if self.model.id in ("dall-e-2", "dall-e-3"): - request["response_format"] = "b64_json" - - return request - - def _parse_usage(self, response_data: dict[str, Any]) -> ImageGenerationUsage: - """Parse usage from response.""" - usage = super()._parse_usage(response_data) - return ImageGenerationUsage(**usage) - - def _parse_content( - self, - response_data: dict[str, Any], - **parameters: Unpack[ImageGenerationParameters], - ) -> ImageArtifact: - """Parse content from response.""" - # Use mixin's _parse_content to get data array - data = super()._parse_content(response_data) - image_data = data[0] - - b64_json = image_data.get("b64_json") - if b64_json: - image_bytes = base64.b64decode(b64_json) - return ImageArtifact(data=image_bytes) - - url = image_data.get("url") - if url: - return ImageArtifact(url=url) - - msg = "No image URL or base64 data in response" - raise ValueError(msg) - - def _parse_finish_reason( - self, response_data: dict[str, Any] - ) -> ImageGenerationFinishReason: - """OpenAI Images API doesn't provide finish reasons.""" - return ImageGenerationFinishReason(reason=None) - - def _stream_class(self) -> type[OpenAIImageGenerationStream]: - """Return the Stream class for this client.""" - return OpenAIImageGenerationStream - - -__all__ = ["OpenAIImageGenerationClient"] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/openai/parameters.py b/packages/capabilities/image-generation/src/celeste_image_generation/providers/openai/parameters.py deleted file mode 100644 index e8ba1d48..00000000 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/openai/parameters.py +++ /dev/null @@ -1,35 +0,0 @@ -"""OpenAI Images parameter mappers for image generation.""" - -from celeste_openai.images.parameters import ( - PartialImagesMapper as _PartialImagesMapper, -) -from celeste_openai.images.parameters import ( - QualityMapper as _QualityMapper, -) -from celeste_openai.images.parameters import ( - SizeMapper as _SizeMapper, -) - -from celeste.parameters import ParameterMapper -from celeste_image_generation.parameters import ImageGenerationParameter - - -class AspectRatioMapper(_SizeMapper): - name = ImageGenerationParameter.ASPECT_RATIO - - -class PartialImagesMapper(_PartialImagesMapper): - name = ImageGenerationParameter.PARTIAL_IMAGES - - -class QualityMapper(_QualityMapper): - name = ImageGenerationParameter.QUALITY - - -OPENAI_PARAMETER_MAPPERS: list[ParameterMapper] = [ - AspectRatioMapper(), - PartialImagesMapper(), - QualityMapper(), -] - -__all__ = ["OPENAI_PARAMETER_MAPPERS"] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/openai/streaming.py b/packages/capabilities/image-generation/src/celeste_image_generation/providers/openai/streaming.py deleted file mode 100644 index cbf01fa9..00000000 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/openai/streaming.py +++ /dev/null @@ -1,60 +0,0 @@ -"""OpenAI streaming for image generation.""" - -import base64 -import logging -from typing import Any - -from celeste_openai.images.streaming import OpenAIImagesStream - -from celeste.artifacts import ImageArtifact -from celeste.core import UsageField -from celeste_image_generation.io import ImageGenerationChunk, ImageGenerationUsage -from celeste_image_generation.streaming import ImageGenerationStream - -logger = logging.getLogger(__name__) - - -class OpenAIImageGenerationStream(OpenAIImagesStream, ImageGenerationStream): - """OpenAI streaming for image generation.""" - - def _parse_chunk(self, chunk_data: dict[str, Any]) -> ImageGenerationChunk | None: - """Parse chunk from SSE event. - - Uses provider mixin to parse raw SSE event, then wraps in typed chunk. - """ - raw = super()._parse_chunk(chunk_data) - if not raw: - return None - - b64_json = raw.get("content") - if not b64_json: - return None - - image_data = base64.b64decode(b64_json) - artifact = ImageArtifact(data=image_data) - - # Parse usage from raw dict (already mapped to UsageField keys) - usage = None - usage_data = raw.get("usage") - if usage_data: - usage = ImageGenerationUsage( - total_tokens=usage_data.get(UsageField.TOTAL_TOKENS), - input_tokens=usage_data.get(UsageField.INPUT_TOKENS), - output_tokens=usage_data.get(UsageField.OUTPUT_TOKENS), - ) - - return ImageGenerationChunk(content=artifact, usage=usage) - - def _parse_usage(self, chunks: list[ImageGenerationChunk]) -> ImageGenerationUsage: - """Parse usage from chunks. - - Usage is only available in the final completed event. - """ - for chunk in reversed(chunks): - if chunk.usage is not None: - return chunk.usage - - return ImageGenerationUsage() - - -__all__ = ["OpenAIImageGenerationStream"] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/streaming.py b/packages/capabilities/image-generation/src/celeste_image_generation/streaming.py deleted file mode 100644 index 48e96103..00000000 --- a/packages/capabilities/image-generation/src/celeste_image_generation/streaming.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Streaming for image generation.""" - -from abc import abstractmethod -from typing import Any, Unpack - -from celeste.streaming import Stream -from celeste_image_generation.io import ( - ImageGenerationChunk, - ImageGenerationOutput, - ImageGenerationUsage, -) -from celeste_image_generation.parameters import ImageGenerationParameters - - -class ImageGenerationStream( - Stream[ImageGenerationOutput, ImageGenerationParameters, ImageGenerationChunk] -): - """Streaming for image generation.""" - - @abstractmethod - def _parse_chunk(self, event: dict[str, Any]) -> ImageGenerationChunk | None: - """Parse SSE event into Chunk (provider-specific).""" - - def _parse_output( # type: ignore[override] - self, - chunks: list[ImageGenerationChunk], - **parameters: Unpack[ImageGenerationParameters], - ) -> ImageGenerationOutput: - """Assemble chunks into final output. - - For image generation, the final chunk contains the complete image. - Progressive chunks may contain partial/preview images. - """ - if not chunks: - msg = "No chunks received from stream" - raise ValueError(msg) - - # Final chunk contains complete image - content = chunks[-1].content - usage = self._parse_usage(chunks) - finish_reason = chunks[-1].finish_reason if chunks else None - - return ImageGenerationOutput( - content=content, - usage=usage, - finish_reason=finish_reason, - metadata={}, - ) - - @abstractmethod - def _parse_usage(self, chunks: list[ImageGenerationChunk]) -> ImageGenerationUsage: - """Parse usage from chunks (provider-specific).""" - - -__all__ = ["ImageGenerationStream"] diff --git a/packages/capabilities/image-generation/tests/integration_tests/test_image_generation/__init__.py b/packages/capabilities/image-generation/tests/integration_tests/test_image_generation/__init__.py deleted file mode 100644 index e1754940..00000000 --- a/packages/capabilities/image-generation/tests/integration_tests/test_image_generation/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Image generation integration test module.""" diff --git a/packages/capabilities/image-generation/tests/integration_tests/test_image_generation/test_generate.py b/packages/capabilities/image-generation/tests/integration_tests/test_image_generation/test_generate.py deleted file mode 100644 index f869979a..00000000 --- a/packages/capabilities/image-generation/tests/integration_tests/test_image_generation/test_generate.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Integration tests for image generation across all providers.""" - -import pytest - -from celeste import Capability, Provider, create_client - - -@pytest.mark.parametrize( - ("provider", "model", "parameters"), - [ - (Provider.BFL, "flux-2-pro", {"aspect_ratio": "1024x1024"}), - (Provider.OPENAI, "dall-e-2", {}), - (Provider.GOOGLE, "imagen-4.0-fast-generate-001", {"num_images": 1}), - (Provider.BYTEPLUS, "seedream-4-0-250828", {}), - ], -) -@pytest.mark.integration -@pytest.mark.asyncio -async def test_generate(provider: Provider, model: str, parameters: dict) -> None: - """Test image generation with prompt parameter across all providers. - - This test demonstrates that the unified API works identically across - all providers using the same code - proving the abstraction value. - Uses cheapest models to minimize costs. - """ - # Import here to avoid circular import during pytest collection - from celeste_image_generation import ( - ImageGenerationOutput, - ImageGenerationUsage, - ) - - from celeste.artifacts import ImageArtifact - - # Arrange - client = create_client( - capability=Capability.IMAGE_GENERATION, - provider=provider, - model=model, - ) - prompt = "A red apple on a white background" - - # Act - response = await client.generate( - prompt=prompt, - **parameters, - ) - - # Assert - assert isinstance(response, ImageGenerationOutput), ( - f"Expected ImageGenerationOutput, got {type(response)}" - ) - assert isinstance(response.content, ImageArtifact), ( - f"Expected ImageArtifact content, got {type(response.content)}" - ) - assert response.content.has_content, ( - f"ImageArtifact has no content (url/data/path): {response.content}" - ) - - # Validate usage metrics - assert isinstance(response.usage, ImageGenerationUsage), ( - f"Expected ImageGenerationUsage, got {type(response.usage)}" - ) diff --git a/packages/capabilities/image-generation/tests/unit_tests/providers/google/__init__.py b/packages/capabilities/image-generation/tests/unit_tests/providers/google/__init__.py deleted file mode 100644 index 2470e91c..00000000 --- a/packages/capabilities/image-generation/tests/unit_tests/providers/google/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Google provider unit tests for image-generation.""" diff --git a/packages/capabilities/image-generation/tests/unit_tests/providers/google/test_finish_reason.py b/packages/capabilities/image-generation/tests/unit_tests/providers/google/test_finish_reason.py deleted file mode 100644 index 7ac0ccc7..00000000 --- a/packages/capabilities/image-generation/tests/unit_tests/providers/google/test_finish_reason.py +++ /dev/null @@ -1,166 +0,0 @@ -"""Unit tests for Google image generation finish reason parsing.""" - -from typing import Any - -import pytest -from celeste_image_generation.providers.google.gemini import GeminiImageGenerationClient -from pydantic import SecretStr - -from celeste.auth import APIKey -from celeste.core import Capability, Provider -from celeste.models import Model - - -class TestParseFinishReason: - """Test _parse_finish_reason method for Gemini image generation client.""" - - @pytest.fixture - def client(self) -> GeminiImageGenerationClient: - """Create a Gemini image generation client for testing.""" - return GeminiImageGenerationClient( - model=Model( - id="gemini-2.5-flash-image", - provider=Provider.GOOGLE, - display_name="Gemini 2.5 Flash Image", - capabilities={Capability.IMAGE_GENERATION}, - ), - provider=Provider.GOOGLE, - capability=Capability.IMAGE_GENERATION, - auth=APIKey(key=SecretStr("test-key")), - ) - - @pytest.mark.parametrize( - ("finish_reason", "finish_message", "expected_reason", "expected_message"), - [ - ("STOP", None, "STOP", None), - ( - "PROHIBITED_CONTENT", - "Content blocked due to policy violation", - "PROHIBITED_CONTENT", - "Content blocked due to policy violation", - ), - ("PROHIBITED_CONTENT", None, "PROHIBITED_CONTENT", None), - ("NO_IMAGE", "Prompt too vague", "NO_IMAGE", "Prompt too vague"), - ( - "SAFETY", - "Safety filters detected inappropriate content", - "SAFETY", - "Safety filters detected inappropriate content", - ), - ], - ids=[ - "stop_without_message", - "prohibited_content_with_message", - "prohibited_content_without_message", - "no_image_with_message", - "safety_with_message", - ], - ) - def test_parse_finish_reason_with_valid_reason( - self, - client: GeminiImageGenerationClient, - finish_reason: str, - finish_message: str | None, - expected_reason: str, - expected_message: str | None, - ) -> None: - """Test parsing finish reason with valid finishReason values.""" - # Arrange - candidate: dict[str, Any] = {"finishReason": finish_reason} - if finish_message is not None: - candidate["finishMessage"] = finish_message - - response_data: dict[str, Any] = { - "candidates": [candidate], - "usageMetadata": {}, - } - - # Act - result = client._parse_finish_reason(response_data) - - # Assert - assert result is not None - assert result.reason == expected_reason - assert result.message == expected_message - - @pytest.mark.parametrize( - "response_data", - [ - {"candidates": [], "usageMetadata": {}}, # Empty candidates - {"predictions": [], "usageMetadata": {}}, # No candidates key (Imagen) - { - "candidates": [ - { - "content": { - "parts": [ - { - "inlineData": { - "mimeType": "image/png", - "data": "base64data", - } - } - ] - } - } - ], - "usageMetadata": {}, - }, # Candidate without finishReason - ], - ids=[ - "empty_candidates", - "no_candidates_key", - "candidate_without_finish_reason", - ], - ) - def test_parse_finish_reason_returns_none_for_invalid_input( - self, - client: GeminiImageGenerationClient, - response_data: dict[str, Any], - ) -> None: - """Test parsing finish reason returns None for invalid/missing input.""" - # Act - result = client._parse_finish_reason(response_data) - - # Assert - assert result is None - - def test_parse_finish_reason_empty_string_finish_reason( - self, client: GeminiImageGenerationClient - ) -> None: - """Test parsing finish reason when finishReason is empty string.""" - # Arrange - response_data: dict[str, Any] = { - "candidates": [{"finishReason": ""}], - "usageMetadata": {}, - } - - # Act - result = client._parse_finish_reason(response_data) - - # Assert - # Empty string is falsy, so should return None - assert result is None - - def test_parse_finish_reason_empty_string_message( - self, client: GeminiImageGenerationClient - ) -> None: - """Test parsing finish reason when finishMessage is empty string.""" - # Arrange - response_data: dict[str, Any] = { - "candidates": [ - { - "finishReason": "STOP", - "finishMessage": "", # Empty string vs None - } - ], - "usageMetadata": {}, - } - - # Act - result = client._parse_finish_reason(response_data) - - # Assert - assert result is not None - assert result.reason == "STOP" - # Empty string is preserved (candidate.get("finishMessage") returns "") - assert result.message == "" diff --git a/packages/capabilities/speech-generation/README.md b/packages/capabilities/speech-generation/README.md deleted file mode 100644 index 9df87cb0..00000000 --- a/packages/capabilities/speech-generation/README.md +++ /dev/null @@ -1,79 +0,0 @@ -
- -# Celeste Logo Celeste Speech Generation - -**Speech Generation capability for Celeste AI** - -[![Python](https://img.shields.io/badge/Python-3.12+-blue?style=for-the-badge)](https://www.python.org/) -[![License](https://img.shields.io/badge/License-MIT-yellow?style=for-the-badge)](../../../LICENSE) - -[Quick Start](#-quick-start) • [Documentation](https://withceleste.ai/docs) • [Request Provider](https://github.com/withceleste/celeste-python/issues/new) - -
- ---- - -## 🚀 Quick Start - -```python -from celeste import create_client, Capability, Provider - -client = create_client( - capability=Capability.SPEECH_GENERATION, - provider=Provider.ELEVENLABS, -) - -response = await client.generate(text="Welcome to Celeste AI. Transform your text into natural, expressive speech with just a few lines of code.") -# response.content is an AudioArtifact with binary audio data -``` - -**Install:** -```bash -uv add "celeste-ai[speech-generation]" -``` - ---- - -## Supported Providers - - -
- -OpenAI -Google -ElevenLabs - - -**Missing a provider?** [Request it](https://github.com/withceleste/celeste-python/issues/new) – ⚡ **we ship fast**. - -
- ---- - -**Streaming**: ✅ Supported - -**Parameters**: See [API Documentation](https://withceleste.ai/docs/api) for full parameter reference. - ---- - -## 🤝 Contributing - -See [CONTRIBUTING.md](../../CONTRIBUTING.md) for guidelines. - -**Request a provider:** [GitHub Issues](https://github.com/withceleste/celeste-python/issues/new) - ---- - -## 📄 License - -MIT 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/packages/capabilities/speech-generation/pyproject.toml b/packages/capabilities/speech-generation/pyproject.toml deleted file mode 100644 index 1058cabe..00000000 --- a/packages/capabilities/speech-generation/pyproject.toml +++ /dev/null @@ -1,50 +0,0 @@ -[project] -name = "celeste-speech-generation" -version = "0.3.7" -description = "Speech generation package for Celeste AI. Unified interface for all providers" -authors = [{name = "Kamilbenkirane", email = "kamil@withceleste.ai"}] -readme = "README.md" -license = {text = "MIT"} -requires-python = ">=3.12" -classifiers = [ - "Development Status :: 3 - Alpha", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT 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", "speech-generation", "tts", "text-to-speech", "openai", "google", "elevenlabs", "gradium", "audio-ai"] -dependencies = [ - "celeste-ai>=0.3.3", - "celeste-elevenlabs>=0.3.3", - "celeste-google>=0.3.3", - "celeste-gradium>=0.3.3", - "celeste-openai>=0.3.3", -] - -[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" - -[tool.uv.sources] -celeste-ai = { workspace = true } -celeste-elevenlabs = { workspace = true } -celeste-google = { workspace = true } -celeste-gradium = { workspace = true } -celeste-openai = { workspace = true } - -[project.entry-points."celeste.packages"] -speech-generation = "celeste_speech_generation:register_package" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/celeste_speech_generation"] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/__init__.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/__init__.py deleted file mode 100644 index 325c9a8f..00000000 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/__init__.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Celeste speech generation capability.""" - - -def register_package() -> None: - """Register speech generation package (client, models, and input).""" - from celeste.client import register_client - from celeste.core import Capability - from celeste.io import register_input - from celeste.models import register_models - from celeste_speech_generation.io import SpeechGenerationInput - from celeste_speech_generation.models import MODELS - from celeste_speech_generation.providers import PROVIDERS - - for provider, client_class in PROVIDERS: - register_client(Capability.SPEECH_GENERATION, provider, client_class) - - register_models(MODELS, capability=Capability.SPEECH_GENERATION) - register_input(Capability.SPEECH_GENERATION, SpeechGenerationInput) - - -from celeste_speech_generation.io import ( # noqa: E402 - SpeechGenerationChunk, - SpeechGenerationInput, - SpeechGenerationOutput, - SpeechGenerationUsage, -) -from celeste_speech_generation.languages import Language # noqa: E402 - -# Aggregate voices from all providers (after Voice is imported) -from celeste_speech_generation.providers.elevenlabs.voices import ( # noqa: E402 - ELEVENLABS_VOICES, -) -from celeste_speech_generation.providers.google.voices import ( # noqa: E402 - GOOGLE_VOICES, -) -from celeste_speech_generation.providers.gradium.voices import ( # noqa: E402 - GRADIUM_VOICES, -) -from celeste_speech_generation.providers.openai.voices import ( # noqa: E402 - OPENAI_VOICES, -) -from celeste_speech_generation.streaming import SpeechGenerationStream # noqa: E402 -from celeste_speech_generation.voices import Voice # noqa: E402 - -VOICES: list[Voice] = [ - *ELEVENLABS_VOICES, - *GOOGLE_VOICES, - *GRADIUM_VOICES, - *OPENAI_VOICES, -] - -__all__ = [ - "VOICES", - "Language", - "SpeechGenerationChunk", - "SpeechGenerationInput", - "SpeechGenerationOutput", - "SpeechGenerationStream", - "SpeechGenerationUsage", - "Voice", - "register_package", -] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/client.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/client.py deleted file mode 100644 index 9e8f0890..00000000 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/client.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Base client for speech generation.""" - -from abc import abstractmethod -from typing import Any, Unpack - -import httpx - -from celeste.artifacts import AudioArtifact -from celeste.client import Client -from celeste.exceptions import ValidationError -from celeste_speech_generation.io import ( - SpeechGenerationInput, - SpeechGenerationOutput, - SpeechGenerationUsage, -) -from celeste_speech_generation.parameters import SpeechGenerationParameters - - -class SpeechGenerationClient( - Client[SpeechGenerationInput, SpeechGenerationOutput, SpeechGenerationParameters] -): - """Client for speech generation operations.""" - - @abstractmethod - def _init_request(self, inputs: SpeechGenerationInput) -> dict[str, Any]: - """Initialize provider-specific request structure.""" - - @abstractmethod - def _parse_usage(self, response_data: dict[str, Any]) -> SpeechGenerationUsage: - """Parse usage information from provider response.""" - - @abstractmethod - def _parse_content( - self, - response_data: dict[str, Any], - **parameters: Unpack[SpeechGenerationParameters], - ) -> AudioArtifact: - """Parse content from provider response.""" - - def _create_inputs( - self, *args: str, **parameters: Unpack[SpeechGenerationParameters] - ) -> SpeechGenerationInput: - """Map positional arguments to Input type.""" - if args: - return SpeechGenerationInput(text=args[0]) - text: str | None = parameters.get("text") - if text is None: - msg = "text is required (either as positional argument or keyword argument)" - raise ValidationError(msg) - return SpeechGenerationInput(text=text) - - @classmethod - def _output_class(cls) -> type[SpeechGenerationOutput]: - """Return the Output class for this client.""" - return SpeechGenerationOutput - - def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: - """Build metadata dictionary from response data.""" - metadata = super()._build_metadata(response_data) - metadata["raw_response"] = response_data - return metadata - - @abstractmethod - async def _make_request( - self, - request_body: dict[str, Any], - **parameters: Unpack[SpeechGenerationParameters], - ) -> httpx.Response: - """Make HTTP request(s) and return response object.""" diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/io.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/io.py deleted file mode 100644 index 13906ab7..00000000 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/io.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Input and output types for speech generation.""" - -from pydantic import Field - -from celeste.artifacts import AudioArtifact -from celeste.io import Chunk, FinishReason, Input, Output, Usage - - -class SpeechGenerationInput(Input): - """Input for speech generation operations.""" - - text: str - - -class SpeechGenerationUsage(Usage): - """Speech generation usage metrics. - - All fields optional since providers vary. - """ - - -class SpeechGenerationFinishReason(FinishReason): - """Finish reason for speech generation.""" - - -class SpeechGenerationOutput(Output[AudioArtifact]): - """Output with audio artifact content.""" - - usage: SpeechGenerationUsage = Field(default_factory=SpeechGenerationUsage) - finish_reason: SpeechGenerationFinishReason | None = None - - -class SpeechGenerationChunk(Chunk[bytes]): - """Typed chunk for speech generation streaming. - - Speech streaming sends raw bytes without finish_reason. - """ - - usage: SpeechGenerationUsage | None = None - - -__all__ = [ - "SpeechGenerationChunk", - "SpeechGenerationFinishReason", - "SpeechGenerationInput", - "SpeechGenerationOutput", - "SpeechGenerationUsage", -] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/models.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/models.py deleted file mode 100644 index 1cd16031..00000000 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/models.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Model definitions for speech generation.""" - -from celeste import Model -from celeste_speech_generation.providers.elevenlabs.models import ( - MODELS as ELEVENLABS_MODELS, -) -from celeste_speech_generation.providers.google.models import MODELS as GOOGLE_MODELS -from celeste_speech_generation.providers.gradium.models import MODELS as GRADIUM_MODELS -from celeste_speech_generation.providers.openai.models import MODELS as OPENAI_MODELS - -MODELS: list[Model] = [ - *GOOGLE_MODELS, - *OPENAI_MODELS, - *ELEVENLABS_MODELS, - *GRADIUM_MODELS, -] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/parameters.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/parameters.py deleted file mode 100644 index 784da3e9..00000000 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/parameters.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Parameters for speech generation.""" - -from enum import StrEnum - -from celeste.parameters import Parameters - - -class SpeechGenerationParameter(StrEnum): - """Unified parameter names for speech generation capability.""" - - VOICE = "voice" - SPEED = "speed" - OUTPUT_FORMAT = "output_format" - PROMPT = "prompt" - LANGUAGE = "language" - - -class SpeechGenerationParameters(Parameters): - """Parameters for speech generation.""" - - voice: str | None - speed: float | None - output_format: str | None - prompt: str | None diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/__init__.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/__init__.py deleted file mode 100644 index 9fdb918d..00000000 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/__init__.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Provider implementations for speech generation.""" - -from celeste import Client, Provider - -__all__ = ["PROVIDERS"] - - -def _get_providers() -> list[tuple[Provider, type[Client]]]: - """Lazy-load providers.""" - # Import clients directly from .client modules to avoid __init__.py imports - from celeste_speech_generation.providers.elevenlabs.client import ( - ElevenLabsSpeechGenerationClient, - ) - from celeste_speech_generation.providers.google.client import ( - GoogleSpeechGenerationClient, - ) - from celeste_speech_generation.providers.gradium.client import ( - GradiumSpeechGenerationClient, - ) - from celeste_speech_generation.providers.openai.client import ( - OpenAISpeechGenerationClient, - ) - - return [ - (Provider.GOOGLE, GoogleSpeechGenerationClient), - (Provider.OPENAI, OpenAISpeechGenerationClient), - (Provider.ELEVENLABS, ElevenLabsSpeechGenerationClient), - (Provider.GRADIUM, GradiumSpeechGenerationClient), - ] - - -PROVIDERS: list[tuple[Provider, type[Client]]] = _get_providers() diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/__init__.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/__init__.py deleted file mode 100644 index 8de72b1e..00000000 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""ElevenLabs provider for speech generation.""" - -from .client import ElevenLabsSpeechGenerationClient -from .models import MODELS -from .streaming import ElevenLabsSpeechGenerationStream - -__all__ = [ - "MODELS", - "ElevenLabsSpeechGenerationClient", - "ElevenLabsSpeechGenerationStream", -] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/client.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/client.py deleted file mode 100644 index 54f7ddad..00000000 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/client.py +++ /dev/null @@ -1,97 +0,0 @@ -"""ElevenLabs client implementation for speech generation.""" - -from typing import Any, Unpack - -from celeste_elevenlabs.text_to_speech.client import ElevenLabsTextToSpeechClient - -from celeste.artifacts import AudioArtifact -from celeste.parameters import ParameterMapper -from celeste_speech_generation.client import SpeechGenerationClient -from celeste_speech_generation.io import ( - SpeechGenerationInput, - SpeechGenerationOutput, - SpeechGenerationUsage, -) -from celeste_speech_generation.parameters import ( - SpeechGenerationParameter, - SpeechGenerationParameters, -) - -from .parameters import ELEVENLABS_PARAMETER_MAPPERS -from .streaming import ElevenLabsSpeechGenerationStream - - -class ElevenLabsSpeechGenerationClient( - ElevenLabsTextToSpeechClient, SpeechGenerationClient -): - """ElevenLabs client for speech generation.""" - - @classmethod - def parameter_mappers(cls) -> list[ParameterMapper]: - return ELEVENLABS_PARAMETER_MAPPERS - - def _init_request(self, inputs: SpeechGenerationInput) -> dict[str, Any]: - """Initialize request from ElevenLabs API format.""" - return {"text": inputs.text} - - def _parse_usage(self, response_data: dict[str, Any]) -> SpeechGenerationUsage: - """Parse usage from response.""" - usage = super()._parse_usage(response_data) - return SpeechGenerationUsage(**usage) - - def _parse_content( - self, - response_data: dict[str, Any], - **parameters: Unpack[SpeechGenerationParameters], - ) -> AudioArtifact: - """Parse content from response. - - Note: This method is not used for ElevenLabs TTS since we override generate() - to handle binary responses. Kept for interface compliance. - """ - # This should never be called for ElevenLabs TTS - msg = "ElevenLabs TTS returns binary responses, use generate() override" - raise NotImplementedError(msg) - - async def generate( - self, - *args: str, - **parameters: Unpack[SpeechGenerationParameters], - ) -> SpeechGenerationOutput: - """Generate speech from text. - - Override base generate() to handle binary audio response from ElevenLabs TTS. - """ - inputs = self._create_inputs(*args, **parameters) - inputs, parameters = self._validate_artifacts(inputs, **parameters) - request_body = self._build_request(inputs, **parameters) - response = await self._make_request(request_body, **parameters) - self._handle_error_response(response) - - # Handle binary response (ElevenLabs TTS returns raw audio bytes, not JSON) - audio_bytes = response.content - if not audio_bytes: - msg = "No audio data in response" - raise ValueError(msg) - - # Determine MIME type from output_format parameter - output_format = ( - parameters.get(SpeechGenerationParameter.OUTPUT_FORMAT) or "mp3_44100_128" - ) - mime_type = self._map_output_format_to_mime_type(output_format) - - # Extract headers from response (ElevenLabs returns metadata like request-id in headers) - headers_dict = dict(response.headers) - - return self._output_class()( - content=AudioArtifact(data=audio_bytes, mime_type=mime_type), - usage=SpeechGenerationUsage(), - metadata=self._build_metadata(headers_dict), - ) - - def _stream_class(self) -> type[ElevenLabsSpeechGenerationStream]: - """Return the Stream class for this client.""" - return ElevenLabsSpeechGenerationStream - - -__all__ = ["ElevenLabsSpeechGenerationClient"] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/models.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/models.py deleted file mode 100644 index 7fc76481..00000000 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/models.py +++ /dev/null @@ -1,141 +0,0 @@ -"""ElevenLabs models for speech generation.""" - -from celeste import Model, Provider -from celeste.constraints import Choice, Range -from celeste_speech_generation.constraints import VoiceConstraint -from celeste_speech_generation.languages import Language -from celeste_speech_generation.parameters import SpeechGenerationParameter - -from .voices import ELEVENLABS_VOICES - -# Valid output formats for ElevenLabs API -ELEVENLABS_OUTPUT_FORMATS = [ - "mp3_22050_32", - "mp3_44100_32", - "mp3_44100_64", - "mp3_44100_96", - "mp3_44100_128", - "mp3_44100_192", - "pcm_8000", - "pcm_16000", - "pcm_22050", - "pcm_24000", - "pcm_44100", - "pcm_48000", - "ulaw_8000", - "alaw_8000", - "opus_48000_32", - "opus_48000_64", - "opus_48000_96", - "opus_48000_128", - "opus_48000_192", -] - -MODELS: list[Model] = [ - Model( - id="eleven_v3", - provider=Provider.ELEVENLABS, - display_name="Eleven v3 (Alpha)", - streaming=True, - parameter_constraints={ - SpeechGenerationParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), - SpeechGenerationParameter.SPEED: Range(min=0.7, max=1.2), - SpeechGenerationParameter.OUTPUT_FORMAT: Choice( - options=ELEVENLABS_OUTPUT_FORMATS - ), - }, - ), - Model( - id="eleven_multilingual_v2", - provider=Provider.ELEVENLABS, - display_name="Eleven Multilingual v2", - streaming=True, - parameter_constraints={ - SpeechGenerationParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), - SpeechGenerationParameter.SPEED: Range(min=0.7, max=1.2), - SpeechGenerationParameter.OUTPUT_FORMAT: Choice( - options=ELEVENLABS_OUTPUT_FORMATS - ), - }, - ), - Model( - id="eleven_turbo_v2_5", - provider=Provider.ELEVENLABS, - display_name="Eleven Turbo v2.5", - streaming=True, - parameter_constraints={ - SpeechGenerationParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), - SpeechGenerationParameter.SPEED: Range(min=0.7, max=1.2), - SpeechGenerationParameter.LANGUAGE: Choice(options=list(Language)), - SpeechGenerationParameter.OUTPUT_FORMAT: Choice( - options=ELEVENLABS_OUTPUT_FORMATS - ), - }, - ), - Model( - id="eleven_turbo_v2", - provider=Provider.ELEVENLABS, - display_name="Eleven Turbo v2", - streaming=True, - parameter_constraints={ - SpeechGenerationParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), - SpeechGenerationParameter.SPEED: Range(min=0.7, max=1.2), - SpeechGenerationParameter.OUTPUT_FORMAT: Choice( - options=ELEVENLABS_OUTPUT_FORMATS - ), - }, - ), - Model( - id="eleven_flash_v2_5", - provider=Provider.ELEVENLABS, - display_name="Eleven Flash v2.5", - streaming=True, - parameter_constraints={ - SpeechGenerationParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), - SpeechGenerationParameter.SPEED: Range(min=0.7, max=1.2), - SpeechGenerationParameter.LANGUAGE: Choice(options=list(Language)), - SpeechGenerationParameter.OUTPUT_FORMAT: Choice( - options=ELEVENLABS_OUTPUT_FORMATS - ), - }, - ), - Model( - id="eleven_flash_v2", - provider=Provider.ELEVENLABS, - display_name="Eleven Flash v2", - streaming=True, - parameter_constraints={ - SpeechGenerationParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), - SpeechGenerationParameter.SPEED: Range(min=0.7, max=1.2), - SpeechGenerationParameter.OUTPUT_FORMAT: Choice( - options=ELEVENLABS_OUTPUT_FORMATS - ), - }, - ), - Model( - id="eleven_multilingual_v1", - provider=Provider.ELEVENLABS, - display_name="Eleven Multilingual v1", - streaming=True, - parameter_constraints={ - SpeechGenerationParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), - SpeechGenerationParameter.SPEED: Range(min=0.7, max=1.2), - SpeechGenerationParameter.OUTPUT_FORMAT: Choice( - options=ELEVENLABS_OUTPUT_FORMATS - ), - }, - ), - Model( - id="eleven_monolingual_v1", - provider=Provider.ELEVENLABS, - display_name="Eleven English v1", - streaming=True, - parameter_constraints={ - SpeechGenerationParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), - SpeechGenerationParameter.SPEED: Range(min=0.7, max=1.2), - SpeechGenerationParameter.OUTPUT_FORMAT: Choice( - options=ELEVENLABS_OUTPUT_FORMATS - ), - }, - ), -] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/parameters.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/parameters.py deleted file mode 100644 index 6d8fd72e..00000000 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/parameters.py +++ /dev/null @@ -1,43 +0,0 @@ -"""ElevenLabs Text To Speech parameter mappers for speech generation.""" - -from celeste_elevenlabs.text_to_speech.parameters import ( - LanguageCodeMapper as _LanguageCodeMapper, -) -from celeste_elevenlabs.text_to_speech.parameters import ( - OutputFormatMapper as _OutputFormatMapper, -) -from celeste_elevenlabs.text_to_speech.parameters import ( - SpeedMapper as _SpeedMapper, -) -from celeste_elevenlabs.text_to_speech.parameters import ( - VoiceMapper as _VoiceMapper, -) - -from celeste.parameters import ParameterMapper -from celeste_speech_generation.parameters import SpeechGenerationParameter - - -class VoiceMapper(_VoiceMapper): - name = SpeechGenerationParameter.VOICE - - -class OutputFormatMapper(_OutputFormatMapper): - name = SpeechGenerationParameter.OUTPUT_FORMAT - - -class SpeedMapper(_SpeedMapper): - name = SpeechGenerationParameter.SPEED - - -class LanguageCodeMapper(_LanguageCodeMapper): - name = SpeechGenerationParameter.LANGUAGE - - -ELEVENLABS_PARAMETER_MAPPERS: list[ParameterMapper] = [ - VoiceMapper(), - OutputFormatMapper(), - SpeedMapper(), - LanguageCodeMapper(), -] - -__all__ = ["ELEVENLABS_PARAMETER_MAPPERS"] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/streaming.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/streaming.py deleted file mode 100644 index af2545b1..00000000 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/streaming.py +++ /dev/null @@ -1,44 +0,0 @@ -"""ElevenLabs streaming for speech generation.""" - -from typing import Any - -from celeste_speech_generation.io import ( - SpeechGenerationChunk, - SpeechGenerationUsage, -) -from celeste_speech_generation.streaming import SpeechGenerationStream - - -class ElevenLabsSpeechGenerationStream(SpeechGenerationStream): - """ElevenLabs streaming for speech generation.""" - - def _parse_chunk(self, event: dict[str, Any]) -> SpeechGenerationChunk | None: - """Parse binary audio chunk from event dict. - - Event dict contains {"data": bytes} for binary audio chunks. - """ - audio_bytes = event.get("data") - if audio_bytes is None: - return None - - if not isinstance(audio_bytes, bytes): - return None - - # Return chunk with binary audio data - return SpeechGenerationChunk( - content=audio_bytes, - usage=None, # Usage calculated in _parse_usage() - metadata={"content_length": len(audio_bytes)}, - ) - - def _parse_usage( - self, chunks: list[SpeechGenerationChunk] - ) -> SpeechGenerationUsage: - """Parse usage from chunks. - - ElevenLabs doesn't return usage in streaming response. - """ - return SpeechGenerationUsage() - - -__all__ = ["ElevenLabsSpeechGenerationStream"] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/__init__.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/__init__.py deleted file mode 100644 index 35519f93..00000000 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Google provider for speech generation.""" - -from .client import GoogleSpeechGenerationClient -from .models import MODELS - -__all__ = ["MODELS", "GoogleSpeechGenerationClient"] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/client.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/client.py deleted file mode 100644 index 1baabdf8..00000000 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/client.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Google client implementation for speech generation.""" - -import base64 -from typing import Any, Unpack - -from celeste_google.cloud_tts.client import GoogleCloudTTSClient - -from celeste.artifacts import AudioArtifact -from celeste.mime_types import AudioMimeType -from celeste.parameters import ParameterMapper -from celeste_speech_generation.client import SpeechGenerationClient -from celeste_speech_generation.io import ( - SpeechGenerationFinishReason, - SpeechGenerationInput, - SpeechGenerationUsage, -) -from celeste_speech_generation.parameters import ( - SpeechGenerationParameter, - SpeechGenerationParameters, -) - -from .parameters import GOOGLE_PARAMETER_MAPPERS - - -class GoogleSpeechGenerationClient(GoogleCloudTTSClient, SpeechGenerationClient): - """Google client for speech generation.""" - - @classmethod - def parameter_mappers(cls) -> list[ParameterMapper]: - return GOOGLE_PARAMETER_MAPPERS - - def _init_request(self, inputs: SpeechGenerationInput) -> dict[str, Any]: - """Initialize request from Cloud TTS API format.""" - return { - "input": {"text": inputs.text}, - "voice": {"modelName": self.model.id}, - "audioConfig": {}, - } - - def _parse_usage(self, response_data: dict[str, Any]) -> SpeechGenerationUsage: - """Parse usage from response.""" - usage = super()._parse_usage(response_data) - return SpeechGenerationUsage(**usage) - - def _parse_finish_reason( - self, response_data: dict[str, Any] - ) -> SpeechGenerationFinishReason: - """Parse finish reason from response.""" - finish_reason = super()._parse_finish_reason(response_data) - return SpeechGenerationFinishReason(reason=finish_reason.reason) - - def _parse_content( - self, - response_data: dict[str, Any], - **parameters: Unpack[SpeechGenerationParameters], - ) -> AudioArtifact: - """Parse content from response.""" - audio_b64 = super()._parse_content(response_data) - audio_bytes = base64.b64decode(audio_b64) - - # Get output_format from parameters, default to MP3 - output_format = parameters.get(SpeechGenerationParameter.OUTPUT_FORMAT) - mime_type = self._get_mime_type(output_format) - - return AudioArtifact( - data=audio_bytes, - mime_type=mime_type, - metadata={"format": str(mime_type)}, - ) - - def _get_mime_type(self, output_format: str | None) -> AudioMimeType: - """Get AudioMimeType from output_format parameter.""" - if output_format is None: - return AudioMimeType.MP3 - - return AudioMimeType(output_format) - - -__all__ = ["GoogleSpeechGenerationClient"] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/mappings.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/mappings.py deleted file mode 100644 index 2b8701a6..00000000 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/mappings.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Google TTS parameter mappings.""" - -from celeste.mime_types import AudioMimeType -from celeste_speech_generation.languages import Language - -# Map Language enum values (ISO 639-1) to Google BCP-47 locale codes -LOCALE_MAP: dict[str, str] = { - Language.ARABIC: "ar-EG", - Language.GERMAN: "de-DE", - Language.ENGLISH: "en-US", - Language.SPANISH: "es-US", - Language.FRENCH: "fr-FR", - Language.HINDI: "hi-IN", - Language.INDONESIAN: "id-ID", - Language.ITALIAN: "it-IT", - Language.JAPANESE: "ja-JP", - Language.KOREAN: "ko-KR", - Language.PORTUGUESE: "pt-BR", - Language.RUSSIAN: "ru-RU", - Language.DUTCH: "nl-NL", - Language.POLISH: "pl-PL", - Language.THAI: "th-TH", - Language.TURKISH: "tr-TR", - Language.VIETNAMESE: "vi-VN", - Language.ROMANIAN: "ro-RO", - Language.UKRAINIAN: "uk-UA", - Language.TAMIL: "ta-IN", -} - -ENCODING_MAP: dict[AudioMimeType, str] = { - AudioMimeType.MP3: "MP3", - AudioMimeType.WAV: "LINEAR16", - AudioMimeType.OGG: "OGG_OPUS", - AudioMimeType.PCM: "PCM", -} - -__all__ = ["ENCODING_MAP", "LOCALE_MAP"] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/parameters.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/parameters.py deleted file mode 100644 index 71e0b44b..00000000 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/parameters.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Google Cloud TTS parameter mappers for speech generation.""" - -from celeste_google.cloud_tts.parameters import ( - AudioEncodingMapper as _AudioEncodingMapper, -) -from celeste_google.cloud_tts.parameters import ( - LanguageMapper as _LanguageMapper, -) -from celeste_google.cloud_tts.parameters import ( - PromptMapper as _PromptMapper, -) -from celeste_google.cloud_tts.parameters import ( - VoiceMapper as _VoiceMapper, -) - -from celeste.parameters import ParameterMapper -from celeste_speech_generation.parameters import SpeechGenerationParameter - -from .mappings import ENCODING_MAP, LOCALE_MAP - - -class VoiceMapper(_VoiceMapper): - name = SpeechGenerationParameter.VOICE - - -class LanguageMapper(_LanguageMapper): - name = SpeechGenerationParameter.LANGUAGE - locale_map = LOCALE_MAP - - -class PromptMapper(_PromptMapper): - name = SpeechGenerationParameter.PROMPT - - -class OutputFormatMapper(_AudioEncodingMapper): - name = SpeechGenerationParameter.OUTPUT_FORMAT - encoding_map = ENCODING_MAP - - -GOOGLE_PARAMETER_MAPPERS: list[ParameterMapper] = [ - VoiceMapper(), - LanguageMapper(), - PromptMapper(), - OutputFormatMapper(), -] - -__all__ = ["GOOGLE_PARAMETER_MAPPERS"] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/__init__.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/__init__.py deleted file mode 100644 index bc01c62b..00000000 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Gradium provider for speech generation.""" - -from .client import GradiumSpeechGenerationClient -from .models import MODELS - -__all__ = [ - "MODELS", - "GradiumSpeechGenerationClient", -] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/client.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/client.py deleted file mode 100644 index 9521e858..00000000 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/client.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Gradium client implementation for speech generation.""" - -from typing import Any, Unpack - -import httpx -from celeste_gradium.text_to_speech.client import GradiumTextToSpeechClient - -from celeste.artifacts import AudioArtifact -from celeste.parameters import ParameterMapper -from celeste_speech_generation.client import SpeechGenerationClient -from celeste_speech_generation.io import ( - SpeechGenerationInput, - SpeechGenerationOutput, - SpeechGenerationUsage, -) -from celeste_speech_generation.parameters import SpeechGenerationParameters - -from .parameters import GRADIUM_PARAMETER_MAPPERS - - -class GradiumSpeechGenerationClient(GradiumTextToSpeechClient, SpeechGenerationClient): - """Gradium client for speech generation.""" - - @classmethod - def parameter_mappers(cls) -> list[ParameterMapper]: - return GRADIUM_PARAMETER_MAPPERS - - def _init_request(self, inputs: SpeechGenerationInput) -> dict[str, Any]: - """Initialize request from Gradium API format.""" - return {"text": inputs.text} - - def _parse_usage(self, response_data: dict[str, Any]) -> SpeechGenerationUsage: - """Parse usage from response.""" - usage = super()._parse_usage(response_data) - return SpeechGenerationUsage(**usage) - - def _parse_content( - self, - response_data: dict[str, Any], - **parameters: Unpack[SpeechGenerationParameters], - ) -> AudioArtifact: - """Parse content from response. - - Note: This method is not used for Gradium TTS since we override generate() - to handle WebSocket responses. Kept for interface compliance. - """ - msg = "Gradium TTS uses WebSocket, use generate() override" - raise NotImplementedError(msg) - - async def _make_request( - self, - request_body: dict[str, Any], - **parameters: Unpack[SpeechGenerationParameters], - ) -> httpx.Response: - """Make HTTP request. - - Note: This method is not used for Gradium TTS since we override generate() - to use WebSocket. Kept for interface compliance. - """ - msg = "Gradium TTS uses WebSocket, use generate() override" - raise NotImplementedError(msg) - - async def generate( - self, - *args: str, - **parameters: Unpack[SpeechGenerationParameters], - ) -> SpeechGenerationOutput: - """Generate speech from text. - - Override base generate() to use WebSocket instead of HTTP. - """ - inputs = self._create_inputs(*args, **parameters) - inputs, parameters = self._validate_artifacts(inputs, **parameters) - request_body = self._build_request(inputs, **parameters) - - # Use WebSocket TTS flow - audio_bytes, output_format = await self._websocket_tts(request_body) - - if not audio_bytes: - msg = "No audio data in response" - raise ValueError(msg) - - # Determine MIME type from output_format - mime_type = self._map_output_format_to_mime_type(output_format) - - return self._output_class()( - content=AudioArtifact(data=audio_bytes, mime_type=mime_type), - usage=SpeechGenerationUsage(), - metadata=self._build_metadata({}), - ) - - -__all__ = ["GradiumSpeechGenerationClient"] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/parameters.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/parameters.py deleted file mode 100644 index e66e934b..00000000 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/parameters.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Gradium Text-to-Speech parameter mappers for speech generation.""" - -from typing import Any - -from celeste_gradium.text_to_speech.parameters import ( - OutputFormatMapper as _OutputFormatMapper, -) -from celeste_gradium.text_to_speech.parameters import ( - PaddingBonusMapper as _PaddingBonusMapper, -) -from celeste_gradium.text_to_speech.parameters import ( - VoiceMapper as _VoiceMapper, -) - -from celeste.models import Model -from celeste.parameters import ParameterMapper -from celeste_speech_generation.parameters import SpeechGenerationParameter - - -class VoiceMapper(_VoiceMapper): - name = SpeechGenerationParameter.VOICE - - -class OutputFormatMapper(_OutputFormatMapper): - name = SpeechGenerationParameter.OUTPUT_FORMAT - - -class SpeedMapper(_PaddingBonusMapper): - """Translate unified speed to Gradium padding_bonus. - - speed 1.0 → padding_bonus 0 (normal) - speed 0.5 → padding_bonus 2.0 (slower) - speed 2.0 → padding_bonus -4.0 (faster) - """ - - name = SpeechGenerationParameter.SPEED - - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Transform speed into provider request.""" - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - # Translate: speed → padding_bonus - padding_bonus = (1.0 - validated_value) * 4.0 - return super().map(request, padding_bonus, model) - - -GRADIUM_PARAMETER_MAPPERS: list[ParameterMapper] = [ - VoiceMapper(), - OutputFormatMapper(), - SpeedMapper(), -] - -__all__ = ["GRADIUM_PARAMETER_MAPPERS"] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/__init__.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/__init__.py deleted file mode 100644 index 0887c667..00000000 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""OpenAI provider for speech generation.""" - -from .client import OpenAISpeechGenerationClient -from .models import MODELS - -__all__ = ["MODELS", "OpenAISpeechGenerationClient"] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/client.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/client.py deleted file mode 100644 index a21fec62..00000000 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/client.py +++ /dev/null @@ -1,88 +0,0 @@ -"""OpenAI client implementation for speech generation.""" - -from typing import Any, Unpack - -from celeste_openai.audio.client import OpenAIAudioClient - -from celeste.artifacts import AudioArtifact -from celeste.parameters import ParameterMapper -from celeste_speech_generation.client import SpeechGenerationClient -from celeste_speech_generation.io import ( - SpeechGenerationInput, - SpeechGenerationOutput, - SpeechGenerationUsage, -) -from celeste_speech_generation.parameters import ( - SpeechGenerationParameter, - SpeechGenerationParameters, -) - -from .parameters import OPENAI_PARAMETER_MAPPERS - - -class OpenAISpeechGenerationClient(OpenAIAudioClient, SpeechGenerationClient): - """OpenAI client for speech generation.""" - - @classmethod - def parameter_mappers(cls) -> list[ParameterMapper]: - return OPENAI_PARAMETER_MAPPERS - - def _init_request(self, inputs: SpeechGenerationInput) -> dict[str, Any]: - """Initialize request from OpenAI API format.""" - return {"input": inputs.text} - - def _parse_usage(self, response_data: dict[str, Any]) -> SpeechGenerationUsage: - """Parse usage from response.""" - usage = super()._parse_usage(response_data) - return SpeechGenerationUsage(**usage) - - def _parse_content( - self, - response_data: dict[str, Any], - **parameters: Unpack[SpeechGenerationParameters], - ) -> AudioArtifact: - """Parse content from response. - - Note: This method is not used for OpenAI TTS since we override generate() - to handle binary responses. Kept for interface compliance. - """ - # This should never be called for OpenAI TTS - msg = "OpenAI TTS returns binary responses, use generate() override" - raise NotImplementedError(msg) - - async def generate( - self, - *args: str, - **parameters: Unpack[SpeechGenerationParameters], - ) -> SpeechGenerationOutput: - """Generate speech from text. - - Override base generate() to handle binary audio response from OpenAI TTS. - """ - inputs = self._create_inputs(*args, **parameters) - inputs, parameters = self._validate_artifacts(inputs, **parameters) - request_body = self._build_request(inputs, **parameters) - response = await self._make_request(request_body, **parameters) - self._handle_error_response(response) - - # Handle binary response (OpenAI TTS returns raw audio bytes, not JSON) - audio_bytes = response.content - if not audio_bytes: - msg = "No audio data in response" - raise ValueError(msg) - - # Determine MIME type from output_format parameter (default to mp3) - output_format = parameters.get(SpeechGenerationParameter.OUTPUT_FORMAT) or "mp3" - mime_type = self._map_response_format_to_mime_type(output_format) - - # Extract headers from response (OpenAI may return metadata in headers) - headers_dict = dict(response.headers) - - return self._output_class()( - content=AudioArtifact(data=audio_bytes, mime_type=mime_type), - usage=SpeechGenerationUsage(), - metadata=self._build_metadata(headers_dict), - ) - - -__all__ = ["OpenAISpeechGenerationClient"] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/models.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/models.py deleted file mode 100644 index d8dd2cf5..00000000 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/models.py +++ /dev/null @@ -1,61 +0,0 @@ -"""OpenAI models for speech generation.""" - -from celeste import Model, Provider -from celeste.constraints import Choice, Range -from celeste.mime_types import AudioMimeType -from celeste_speech_generation.constraints import VoiceConstraint -from celeste_speech_generation.parameters import SpeechGenerationParameter - -from .voices import GPT4O_MINI_TTS_VOICES, TTS1_HD_VOICES, TTS1_VOICES - -# Common response format options for all OpenAI TTS models -_RESPONSE_FORMAT_OPTIONS = [ - AudioMimeType.MP3, - AudioMimeType.OGG, # Maps to "opus" in OpenAI API - AudioMimeType.AAC, - AudioMimeType.FLAC, -] - -MODELS: list[Model] = [ - Model( - id="tts-1", - provider=Provider.OPENAI, - display_name="TTS-1", - streaming=False, - parameter_constraints={ - SpeechGenerationParameter.VOICE: VoiceConstraint(voices=TTS1_VOICES), - SpeechGenerationParameter.SPEED: Range(min=0.25, max=4.0), - SpeechGenerationParameter.OUTPUT_FORMAT: Choice( - options=_RESPONSE_FORMAT_OPTIONS - ), - }, - ), - Model( - id="tts-1-hd", - provider=Provider.OPENAI, - display_name="TTS-1 HD", - streaming=False, - parameter_constraints={ - SpeechGenerationParameter.VOICE: VoiceConstraint(voices=TTS1_HD_VOICES), - SpeechGenerationParameter.SPEED: Range(min=0.25, max=4.0), - SpeechGenerationParameter.OUTPUT_FORMAT: Choice( - options=_RESPONSE_FORMAT_OPTIONS - ), - }, - ), - Model( - id="gpt-4o-mini-tts", - provider=Provider.OPENAI, - display_name="GPT-4o Mini TTS", - streaming=False, - parameter_constraints={ - SpeechGenerationParameter.VOICE: VoiceConstraint( - voices=GPT4O_MINI_TTS_VOICES - ), - SpeechGenerationParameter.SPEED: Range(min=0.25, max=4.0), - SpeechGenerationParameter.OUTPUT_FORMAT: Choice( - options=_RESPONSE_FORMAT_OPTIONS - ), - }, - ), -] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/parameters.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/parameters.py deleted file mode 100644 index 287c1deb..00000000 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/parameters.py +++ /dev/null @@ -1,35 +0,0 @@ -"""OpenAI Audio parameter mappers for speech generation.""" - -from celeste_openai.audio.parameters import ( - ResponseFormatMapper as _ResponseFormatMapper, -) -from celeste_openai.audio.parameters import ( - SpeedMapper as _SpeedMapper, -) -from celeste_openai.audio.parameters import ( - VoiceMapper as _VoiceMapper, -) - -from celeste.parameters import ParameterMapper -from celeste_speech_generation.parameters import SpeechGenerationParameter - - -class VoiceMapper(_VoiceMapper): - name = SpeechGenerationParameter.VOICE - - -class SpeedMapper(_SpeedMapper): - name = SpeechGenerationParameter.SPEED - - -class OutputFormatMapper(_ResponseFormatMapper): - name = SpeechGenerationParameter.OUTPUT_FORMAT - - -OPENAI_PARAMETER_MAPPERS: list[ParameterMapper] = [ - VoiceMapper(), - SpeedMapper(), - OutputFormatMapper(), -] - -__all__ = ["OPENAI_PARAMETER_MAPPERS"] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/py.typed b/packages/capabilities/speech-generation/src/celeste_speech_generation/py.typed deleted file mode 100644 index 321d0ae1..00000000 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/py.typed +++ /dev/null @@ -1 +0,0 @@ -# Marker file for PEP 561 - this package supports type checking diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/streaming.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/streaming.py deleted file mode 100644 index 84c91110..00000000 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/streaming.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Streaming for speech generation.""" - -from abc import abstractmethod -from typing import Unpack - -from celeste.artifacts import AudioArtifact -from celeste.streaming import Stream -from celeste_speech_generation.io import ( - SpeechGenerationChunk, - SpeechGenerationOutput, - SpeechGenerationUsage, -) -from celeste_speech_generation.parameters import SpeechGenerationParameters - - -class SpeechGenerationStream( - Stream[SpeechGenerationOutput, SpeechGenerationParameters, SpeechGenerationChunk] -): - """Streaming for speech generation.""" - - def _parse_output( # type: ignore[override] - self, - chunks: list[SpeechGenerationChunk], - **parameters: Unpack[SpeechGenerationParameters], - ) -> SpeechGenerationOutput: - """Assemble chunks into final output.""" - # Speech streaming: concatenate raw bytes - audio_bytes = b"".join(chunk.content for chunk in chunks) - usage = self._parse_usage(chunks) - - return SpeechGenerationOutput( - content=AudioArtifact(data=audio_bytes), - usage=usage, - metadata={}, - ) - - @abstractmethod - def _parse_usage( - self, chunks: list[SpeechGenerationChunk] - ) -> SpeechGenerationUsage: - """Parse usage from chunks (provider-specific).""" - - -__all__ = ["SpeechGenerationStream"] diff --git a/packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/__init__.py b/packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/__init__.py deleted file mode 100644 index 23470cb4..00000000 --- a/packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Speech generation integration test module.""" diff --git a/packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/test_generate.py b/packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/test_generate.py deleted file mode 100644 index 57f0276d..00000000 --- a/packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/test_generate.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Integration tests for speech generation across all providers.""" - -import pytest - -from celeste import Capability, Provider, create_client - - -@pytest.mark.parametrize( - ("provider", "model", "parameters"), - [ - (Provider.OPENAI, "tts-1", {"voice": "alloy", "output_format": "mp3"}), - ( - Provider.GOOGLE, - "gemini-2.5-flash-tts", - {"voice": "Zephyr", "speed": 1.0}, - ), - ( - Provider.ELEVENLABS, - "eleven_flash_v2_5", - {"voice": "Rachel", "output_format": "mp3_44100_128"}, - ), - (Provider.GRADIUM, "default", {}), - ], -) -@pytest.mark.integration -@pytest.mark.asyncio -async def test_generate(provider: Provider, model: str, parameters: dict) -> None: - """Test speech generation with voice parameter across all providers. - - This test demonstrates that the unified API works identically across - all providers using the same code - proving the abstraction value. - Uses cheapest models to minimize costs. - """ - # Import here to avoid circular import during pytest collection - from celeste_speech_generation import ( - SpeechGenerationOutput, - SpeechGenerationUsage, - ) - - from celeste.artifacts import AudioArtifact - - # Arrange - client = create_client( - capability=Capability.SPEECH_GENERATION, - provider=provider, - model=model, - ) - text = "Hello, this is a test of the Celeste speech generation capability." - - # Act - response = await client.generate( - text=text, - **parameters, - ) - - # Assert - assert isinstance(response, SpeechGenerationOutput), ( - f"Expected SpeechGenerationOutput, got {type(response)}" - ) - assert isinstance(response.content, AudioArtifact), ( - f"Expected AudioArtifact content, got {type(response.content)}" - ) - assert response.content.has_content, ( - f"AudioArtifact has no content (data/path): {response.content}" - ) - assert response.content.data is not None, "Audio data is None" - assert len(response.content.data) > 0, "Audio data is empty" - - # Validate usage metrics - assert isinstance(response.usage, SpeechGenerationUsage), ( - f"Expected SpeechGenerationUsage, got {type(response.usage)}" - ) diff --git a/packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/test_stream.py b/packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/test_stream.py deleted file mode 100644 index 999a554f..00000000 --- a/packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/test_stream.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Integration tests for speech generation streaming across all providers.""" - -import pytest - -from celeste import Capability, Provider, create_client - - -@pytest.mark.parametrize( - ("provider", "model", "parameters"), - [ - # Only ElevenLabs currently supports streaming for speech generation - # OpenAI and Google TTS do not support streaming in the same way - ( - Provider.ELEVENLABS, - "eleven_flash_v2_5", - {"voice": "Rachel", "output_format": "mp3_44100_128"}, - ), - ], -) -@pytest.mark.integration -@pytest.mark.asyncio -async def test_stream(provider: Provider, model: str, parameters: dict) -> None: - """Test speech generation streaming across supported providers. - - Verifies that we receive audio chunks and can assemble them. - """ - # Import here to avoid circular import during pytest collection - from celeste_speech_generation import ( - SpeechGenerationChunk, - SpeechGenerationUsage, - ) - - # Arrange - client = create_client( - capability=Capability.SPEECH_GENERATION, - provider=provider, - model=model, - ) - text = "Hello, this is a streaming test." - - # Act - chunks = [] - async for chunk in client.stream( - text=text, - **parameters, - ): - assert isinstance(chunk, SpeechGenerationChunk) - chunks.append(chunk) - - # Assert - assert len(chunks) > 0, "No chunks received" - total_size = sum(len(chunk.content) for chunk in chunks) - assert total_size > 0, "Total audio size is 0" - - # Verify usage if present in any chunk - has_usage = any(chunk.usage is not None for chunk in chunks) - if has_usage: - last_usage = next(c.usage for c in reversed(chunks) if c.usage) - assert isinstance(last_usage, SpeechGenerationUsage) diff --git a/packages/capabilities/text-generation/README.md b/packages/capabilities/text-generation/README.md deleted file mode 100644 index cca61ea0..00000000 --- a/packages/capabilities/text-generation/README.md +++ /dev/null @@ -1,81 +0,0 @@ -
- -# Celeste Logo Celeste Text Generation - -**Text Generation capability for Celeste AI** - -[![Python](https://img.shields.io/badge/Python-3.12+-blue?style=for-the-badge)](https://www.python.org/) -[![License](https://img.shields.io/badge/License-MIT-yellow?style=for-the-badge)](../../../LICENSE) - -[Quick Start](#-quick-start) • [Documentation](https://withceleste.ai/docs) • [Request Provider](https://github.com/withceleste/celeste-python/issues/new) - -
- ---- - -## 🚀 Quick Start - -```python -from celeste import create_client, Capability, Provider - -client = create_client( - capability=Capability.TEXT_GENERATION, - provider=Provider.OPENAI, -) - -response = await client.generate(prompt="Hello, world!") -print(response.content) -``` - -**Install:** -```bash -uv add "celeste-ai[text-generation]" -``` - ---- - -## Supported Providers - - -
- -Google -Anthropic -OpenAI -Mistral -Cohere - - -**Missing a provider?** [Request it](https://github.com/withceleste/celeste-python/issues/new) – ⚡ **we ship fast**. - -
- ---- - -**Streaming**: ✅ Supported - -**Parameters**: See [API Documentation](https://withceleste.ai/docs/api) for full parameter reference. - ---- - -## 🤝 Contributing - -See [CONTRIBUTING.md](../../CONTRIBUTING.md) for guidelines. - -**Request a provider:** [GitHub Issues](https://github.com/withceleste/celeste-python/issues/new) - ---- - -## 📄 License - -MIT 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/packages/capabilities/text-generation/pyproject.toml b/packages/capabilities/text-generation/pyproject.toml deleted file mode 100644 index 488111ec..00000000 --- a/packages/capabilities/text-generation/pyproject.toml +++ /dev/null @@ -1,54 +0,0 @@ -[project] -name = "celeste-text-generation" -version = "0.3.8" -description = "Text generation package for Celeste AI. Unified interface for all providers" -authors = [{name = "Kamilbenkirane", email = "kamil@withceleste.ai"}] -readme = "README.md" -license = {text = "MIT"} -requires-python = ">=3.12" -classifiers = [ - "Development Status :: 3 - Alpha", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT 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", "text-generation", "llm", "openai", "anthropic", "claude", "gemini", "mistral", "cohere"] -dependencies = [ - "celeste-ai>=0.3.3", - "celeste-anthropic>=0.3.3", - "celeste-cohere>=0.3.3", - "celeste-google>=0.3.3", - "celeste-mistral>=0.3.3", - "celeste-openai>=0.3.3", - "celeste-xai>=0.3.3", -] - -[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" - -[tool.uv.sources] -celeste-ai = { workspace = true } -celeste-anthropic = { workspace = true } -celeste-cohere = { workspace = true } -celeste-google = { workspace = true } -celeste-mistral = { workspace = true } -celeste-openai = { workspace = true } -celeste-xai = { workspace = true } - -[project.entry-points."celeste.packages"] -text-generation = "celeste_text_generation:register_package" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/celeste_text_generation"] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/__init__.py b/packages/capabilities/text-generation/src/celeste_text_generation/__init__.py deleted file mode 100644 index 89cd7f86..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/__init__.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Celeste text generation capability.""" - - -def register_package() -> None: - """Register text generation package (client, models, and input).""" - from celeste.client import register_client - from celeste.core import Capability - from celeste.io import register_input - from celeste.models import register_models - from celeste_text_generation.io import TextGenerationInput - from celeste_text_generation.models import MODELS - from celeste_text_generation.providers import PROVIDERS - - for provider, client_class in PROVIDERS: - register_client(Capability.TEXT_GENERATION, provider, client_class) - - register_models(MODELS, capability=Capability.TEXT_GENERATION) - register_input(Capability.TEXT_GENERATION, TextGenerationInput) - - -from celeste_text_generation.io import ( # noqa: E402 - TextGenerationChunk, - TextGenerationFinishReason, - TextGenerationInput, - TextGenerationOutput, - TextGenerationUsage, -) -from celeste_text_generation.streaming import TextGenerationStream # noqa: E402 - -__all__ = [ - "TextGenerationChunk", - "TextGenerationFinishReason", - "TextGenerationInput", - "TextGenerationOutput", - "TextGenerationStream", - "TextGenerationUsage", - "register_package", -] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/client.py b/packages/capabilities/text-generation/src/celeste_text_generation/client.py deleted file mode 100644 index 03cc2db6..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/client.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Base client for text generation.""" - -from abc import abstractmethod -from typing import Any, Unpack - -import httpx - -from celeste.client import Client -from celeste.exceptions import ValidationError -from celeste.types import StructuredOutput -from celeste_text_generation.io import ( - TextGenerationFinishReason, - TextGenerationInput, - TextGenerationOutput, - TextGenerationUsage, -) -from celeste_text_generation.parameters import TextGenerationParameters - - -class TextGenerationClient( - Client[TextGenerationInput, TextGenerationOutput, TextGenerationParameters] -): - """Client for text generation operations.""" - - @abstractmethod - def _init_request(self, inputs: TextGenerationInput) -> dict[str, Any]: - """Initialize provider-specific request structure.""" - - @abstractmethod - def _parse_usage(self, response_data: dict[str, Any]) -> TextGenerationUsage: - """Parse usage information from provider response.""" - - @abstractmethod - def _parse_content( - self, - response_data: dict[str, Any], - **parameters: Unpack[TextGenerationParameters], - ) -> StructuredOutput: - """Parse content from provider response.""" - - @abstractmethod - def _parse_finish_reason( - self, response_data: dict[str, Any] - ) -> TextGenerationFinishReason: - """Parse finish reason from provider response.""" - - def _create_inputs( - self, *args: str, **parameters: Unpack[TextGenerationParameters] - ) -> TextGenerationInput: - """Map positional arguments to Input type.""" - if args: - return TextGenerationInput(prompt=args[0]) - prompt: str | None = parameters.get("prompt") - if prompt is None: - msg = ( - "prompt is required (either as positional argument or keyword argument)" - ) - raise ValidationError(msg) - return TextGenerationInput(prompt=prompt) - - @classmethod - def _output_class(cls) -> type[TextGenerationOutput]: - """Return the Output class for this client.""" - return TextGenerationOutput - - def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: - """Build metadata dictionary from response data.""" - metadata = super()._build_metadata(response_data) - metadata["raw_response"] = response_data - return metadata - - @abstractmethod - async def _make_request( - self, - request_body: dict[str, Any], - **parameters: Unpack[TextGenerationParameters], - ) -> httpx.Response: - """Make HTTP request(s) and return response object.""" diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/io.py b/packages/capabilities/text-generation/src/celeste_text_generation/io.py deleted file mode 100644 index bb3b4474..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/io.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Input and output types for text generation.""" - -from pydantic import Field - -from celeste.io import Chunk, FinishReason, Input, Output, Usage - - -class TextGenerationInput(Input): - """Input for text generation operations.""" - - prompt: str - - -class TextGenerationFinishReason(FinishReason): - """Text generation finish reason. - - Stores raw provider reason. Providers map their values in implementation. - """ - - reason: str | None = None - - -class TextGenerationUsage(Usage): - """Text generation usage metrics. - - All fields optional since providers vary. - """ - - input_tokens: int | None = None - output_tokens: int | None = None - total_tokens: int | None = None - billed_tokens: int | None = None - cached_tokens: int | None = None - reasoning_tokens: int | None = None - - -class TextGenerationOutput[Content](Output[Content]): - """Output with text or structured content.""" - - usage: TextGenerationUsage = Field(default_factory=TextGenerationUsage) - finish_reason: TextGenerationFinishReason | None = None - - -class TextGenerationChunk(Chunk[str]): - """Typed chunk for text generation streaming. - - Content is incremental text delta. - """ - - finish_reason: TextGenerationFinishReason | None = None - usage: TextGenerationUsage | None = None - - -__all__ = [ - "TextGenerationChunk", - "TextGenerationFinishReason", - "TextGenerationInput", - "TextGenerationOutput", - "TextGenerationUsage", -] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/models.py b/packages/capabilities/text-generation/src/celeste_text_generation/models.py deleted file mode 100644 index 63081f98..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/models.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Model definitions for text generation.""" - -from celeste import Model -from celeste_text_generation.providers.anthropic.models import ( - MODELS as ANTHROPIC_MODELS, -) -from celeste_text_generation.providers.cohere.models import MODELS as COHERE_MODELS -from celeste_text_generation.providers.google.models import MODELS as GOOGLE_MODELS -from celeste_text_generation.providers.mistral.models import MODELS as MISTRAL_MODELS -from celeste_text_generation.providers.openai.models import MODELS as OPENAI_MODELS -from celeste_text_generation.providers.xai.models import MODELS as XAI_MODELS - -MODELS: list[Model] = [ - *ANTHROPIC_MODELS, - *COHERE_MODELS, - *GOOGLE_MODELS, - *MISTRAL_MODELS, - *OPENAI_MODELS, - *XAI_MODELS, -] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/parameters.py b/packages/capabilities/text-generation/src/celeste_text_generation/parameters.py deleted file mode 100644 index b8b25a34..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/parameters.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Parameters for text generation.""" - -from enum import StrEnum - -from pydantic import BaseModel - -from celeste.parameters import Parameters - - -class TextGenerationParameter(StrEnum): - """Unified parameter names for text generation capability.""" - - THINKING_BUDGET = "thinking_budget" - THINKING_LEVEL = "thinking_level" - OUTPUT_SCHEMA = "output_schema" - VERBOSITY = "verbosity" - - -class TextGenerationParameters(Parameters): - """Parameters for text generation.""" - - temperature: float | None - max_tokens: int | None - thinking_budget: int | None - thinking_level: str | None - verbosity: str | None - output_schema: type[BaseModel] | None diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/__init__.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/__init__.py deleted file mode 100644 index 36b5d55f..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/__init__.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Provider implementations for text generation.""" - -from celeste import Client, Provider - -__all__ = ["PROVIDERS"] - - -def _get_providers() -> list[tuple[Provider, type[Client]]]: - """Lazy-load providers.""" - # Import clients directly from .client modules to avoid __init__.py imports - from celeste_text_generation.providers.anthropic.client import ( - AnthropicTextGenerationClient, - ) - from celeste_text_generation.providers.cohere.client import ( - CohereTextGenerationClient, - ) - from celeste_text_generation.providers.google.client import ( - GoogleTextGenerationClient, - ) - from celeste_text_generation.providers.mistral.client import ( - MistralTextGenerationClient, - ) - from celeste_text_generation.providers.openai.client import ( - OpenAITextGenerationClient, - ) - from celeste_text_generation.providers.xai.client import ( - XAITextGenerationClient, - ) - - return [ - (Provider.ANTHROPIC, AnthropicTextGenerationClient), - (Provider.COHERE, CohereTextGenerationClient), - (Provider.GOOGLE, GoogleTextGenerationClient), - (Provider.MISTRAL, MistralTextGenerationClient), - (Provider.OPENAI, OpenAITextGenerationClient), - (Provider.XAI, XAITextGenerationClient), - ] - - -PROVIDERS: list[tuple[Provider, type[Client]]] = _get_providers() diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/anthropic/__init__.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/anthropic/__init__.py deleted file mode 100644 index f1c0bd84..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/anthropic/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Anthropic provider for text generation.""" - -from .client import AnthropicTextGenerationClient -from .models import MODELS -from .streaming import AnthropicTextGenerationStream - -__all__ = ["MODELS", "AnthropicTextGenerationClient", "AnthropicTextGenerationStream"] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/anthropic/client.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/anthropic/client.py deleted file mode 100644 index 080cff1f..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/anthropic/client.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Anthropic client implementation for text generation.""" - -from typing import Any, Unpack - -from celeste_anthropic.messages.client import AnthropicMessagesClient - -from celeste.parameters import ParameterMapper -from celeste.types import StructuredOutput -from celeste_text_generation.client import TextGenerationClient -from celeste_text_generation.io import ( - TextGenerationFinishReason, - TextGenerationInput, - TextGenerationUsage, -) -from celeste_text_generation.parameters import TextGenerationParameters - -from .parameters import ANTHROPIC_PARAMETER_MAPPERS -from .streaming import AnthropicTextGenerationStream - - -class AnthropicTextGenerationClient(AnthropicMessagesClient, TextGenerationClient): - """Anthropic client for text generation.""" - - @classmethod - def parameter_mappers(cls) -> list[ParameterMapper]: - return ANTHROPIC_PARAMETER_MAPPERS - - def _init_request(self, inputs: TextGenerationInput) -> dict[str, Any]: - """Initialize request from Anthropic Messages API format.""" - return {"messages": [{"role": "user", "content": inputs.prompt}]} - - def _parse_usage(self, response_data: dict[str, Any]) -> TextGenerationUsage: - """Parse usage from response.""" - usage = super()._parse_usage(response_data) - return TextGenerationUsage(**usage) - - def _parse_content( - self, - response_data: dict[str, Any], - **parameters: Unpack[TextGenerationParameters], - ) -> StructuredOutput: - """Parse content from response.""" - content = super()._parse_content(response_data) - - text_content = "" - for content_block in content: - if content_block.get("type") == "text": - text_content = content_block.get("text") or "" - break - - return self._transform_output(text_content, **parameters) - - def _parse_finish_reason( - self, response_data: dict[str, Any] - ) -> TextGenerationFinishReason: - """Parse finish reason from response.""" - finish_reason = super()._parse_finish_reason(response_data) - return TextGenerationFinishReason(reason=finish_reason.reason) - - def _stream_class(self) -> type[AnthropicTextGenerationStream]: - """Return the Stream class for this client.""" - return AnthropicTextGenerationStream - - -__all__ = ["AnthropicTextGenerationClient"] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/anthropic/models.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/anthropic/models.py deleted file mode 100644 index 517d97b9..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/anthropic/models.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Anthropic models for text generation.""" - -from celeste import Model, Provider -from celeste.constraints import Range, Schema -from celeste.core import Parameter -from celeste_text_generation.parameters import TextGenerationParameter - -MODELS: list[Model] = [ - Model( - id="claude-sonnet-4-5", - provider=Provider.ANTHROPIC, - display_name="Claude Sonnet 4.5", - streaming=True, - parameter_constraints={ - Parameter.MAX_TOKENS: Range(min=1, max=64000), - TextGenerationParameter.THINKING_BUDGET: Range(min=-1, max=64000), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), - }, - ), - Model( - id="claude-haiku-4-5", - provider=Provider.ANTHROPIC, - display_name="Claude Haiku 4.5", - streaming=True, - parameter_constraints={ - Parameter.MAX_TOKENS: Range(min=1, max=64000), - TextGenerationParameter.THINKING_BUDGET: Range(min=-1, max=32000), - }, - ), - Model( - id="claude-opus-4-1", - provider=Provider.ANTHROPIC, - display_name="Claude Opus 4.1", - streaming=True, - parameter_constraints={ - Parameter.MAX_TOKENS: Range(min=1, max=32000), - TextGenerationParameter.THINKING_BUDGET: Range(min=-1, max=32000), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), - }, - ), - Model( - id="claude-opus-4-5", - provider=Provider.ANTHROPIC, - display_name="Claude Opus 4.5", - streaming=True, - parameter_constraints={ - Parameter.MAX_TOKENS: Range(min=1, max=64000), - TextGenerationParameter.THINKING_BUDGET: Range(min=-1, max=32000), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), - }, - ), - Model( - id="claude-sonnet-4-20250514", - provider=Provider.ANTHROPIC, - display_name="Claude Sonnet 4", - streaming=True, - parameter_constraints={ - Parameter.MAX_TOKENS: Range(min=1, max=64000), - TextGenerationParameter.THINKING_BUDGET: Range(min=-1, max=64000), - }, - ), - Model( - id="claude-opus-4-20250514", - provider=Provider.ANTHROPIC, - display_name="Claude Opus 4", - streaming=True, - parameter_constraints={ - Parameter.MAX_TOKENS: Range(min=1, max=32000), - TextGenerationParameter.THINKING_BUDGET: Range(min=-1, max=32000), - }, - ), -] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/anthropic/streaming.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/anthropic/streaming.py deleted file mode 100644 index bfcc8e4b..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/anthropic/streaming.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Anthropic streaming for text generation.""" - -from collections.abc import Callable -from typing import Any, Unpack - -from celeste_anthropic.messages.streaming import AnthropicMessagesStream - -from celeste.types import StructuredOutput -from celeste_text_generation.io import ( - TextGenerationChunk, - TextGenerationFinishReason, - TextGenerationOutput, - TextGenerationUsage, -) -from celeste_text_generation.parameters import TextGenerationParameters -from celeste_text_generation.streaming import TextGenerationStream - - -class AnthropicTextGenerationStream(AnthropicMessagesStream, TextGenerationStream): - """Anthropic streaming for text generation.""" - - def __init__( - self, - sse_iterator: Any, # noqa: ANN401 - transform_output: Callable[..., StructuredOutput], - **parameters: Unpack[TextGenerationParameters], - ) -> None: - """Initialize stream with output transformation support.""" - super().__init__(sse_iterator, **parameters) - self._transform_output = transform_output - self._last_finish_reason: TextGenerationFinishReason | None = None - - def _parse_chunk(self, event: dict[str, Any]) -> TextGenerationChunk | None: - """Parse SSE event into typed Chunk.""" - raw = super()._parse_chunk(event) - if not raw: - return None - - usage = TextGenerationUsage(**raw["usage"]) if raw["usage"] else None - finish_reason = ( - TextGenerationFinishReason(reason=raw["finish_reason"]) - if raw["finish_reason"] - else None - ) - - if finish_reason: - self._last_finish_reason = finish_reason - - # For message_stop events, use stored finish_reason - event_type = raw["raw_event"].get("type") - if event_type == "message_stop" and self._last_finish_reason: - finish_reason = self._last_finish_reason - - return TextGenerationChunk( - content=raw["content"], - finish_reason=finish_reason, - usage=usage, - metadata={"raw_event": raw["raw_event"]}, - ) - - def _parse_usage(self, chunks: list[TextGenerationChunk]) -> TextGenerationUsage: - """Extract usage from final chunk.""" - for chunk in reversed(chunks): - if chunk.usage: - return chunk.usage - return TextGenerationUsage() - - def _parse_output( - self, - chunks: list[TextGenerationChunk], - **parameters: Unpack[TextGenerationParameters], - ) -> TextGenerationOutput: - """Assemble chunks into final output.""" - content = "".join(chunk.content for chunk in chunks) - content = self._transform_output(content, **parameters) - - usage = self._parse_usage(chunks) - finish_reason = chunks[-1].finish_reason if chunks else None - - raw_events = [ - c.metadata["raw_event"] - for c in chunks - if c.metadata.get("raw_event", {}).get("type") - in ("message_delta", "message_stop") - ] - - return TextGenerationOutput( - content=content, - usage=usage, - finish_reason=finish_reason, - metadata={"raw_response": raw_events}, - ) - - -__all__ = ["AnthropicTextGenerationStream"] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/__init__.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/__init__.py deleted file mode 100644 index 9d5a91c2..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Cohere provider for text generation.""" - -from .client import CohereTextGenerationClient -from .models import MODELS -from .streaming import CohereTextGenerationStream - -__all__ = ["MODELS", "CohereTextGenerationClient", "CohereTextGenerationStream"] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/client.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/client.py deleted file mode 100644 index b8eac45f..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/client.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Cohere client implementation for text generation.""" - -from typing import Any, Unpack - -from celeste_cohere.chat.client import CohereChatClient - -from celeste.parameters import ParameterMapper -from celeste.types import StructuredOutput -from celeste_text_generation.client import TextGenerationClient -from celeste_text_generation.io import ( - TextGenerationFinishReason, - TextGenerationInput, - TextGenerationUsage, -) -from celeste_text_generation.parameters import TextGenerationParameters - -from .parameters import COHERE_PARAMETER_MAPPERS -from .streaming import CohereTextGenerationStream - - -class CohereTextGenerationClient(CohereChatClient, TextGenerationClient): - """Cohere client for text generation.""" - - @classmethod - def parameter_mappers(cls) -> list[ParameterMapper]: - return COHERE_PARAMETER_MAPPERS - - def _init_request(self, inputs: TextGenerationInput) -> dict[str, Any]: - """Initialize request from Cohere v2 Chat API messages array format.""" - messages = [ - { - "role": "user", - "content": inputs.prompt, - } - ] - - return {"messages": messages} - - def _parse_usage(self, response_data: dict[str, Any]) -> TextGenerationUsage: - """Parse usage from response.""" - usage = super()._parse_usage(response_data) - return TextGenerationUsage(**usage) - - def _parse_content( - self, - response_data: dict[str, Any], - **parameters: Unpack[TextGenerationParameters], - ) -> StructuredOutput: - """Parse content from response.""" - message = response_data.get("message", {}) - content_array = message.get("content", []) - if not content_array: - msg = "No content in response message" - raise ValueError(msg) - - first_content = content_array[0] - text = first_content.get("text") or "" - - return self._transform_output(text, **parameters) - - def _parse_finish_reason( - self, response_data: dict[str, Any] - ) -> TextGenerationFinishReason: - """Parse finish reason from response.""" - finish_reason_str = response_data.get("finish_reason") - return TextGenerationFinishReason(reason=finish_reason_str) - - def _stream_class(self) -> type[CohereTextGenerationStream]: - """Return the Stream class for this client.""" - return CohereTextGenerationStream - - -__all__ = ["CohereTextGenerationClient"] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/models.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/models.py deleted file mode 100644 index 11d0de1a..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/models.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Cohere models for text generation.""" - -from celeste import Model, Provider -from celeste.constraints import Range, Schema -from celeste.core import Parameter -from celeste_text_generation.parameters import TextGenerationParameter - -MODELS: list[Model] = [ - Model( - id="command-a-03-2025", - provider=Provider.COHERE, - display_name="Command A 03-2025", - streaming=True, - parameter_constraints={ - Parameter.TEMPERATURE: Range(min=0.0, max=1.0, step=0.01), - Parameter.MAX_TOKENS: Range(min=1, max=4096, step=1), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), - # thinking_budget: Not confirmed for this model, omit constraint - }, - ), - Model( - id="command-r7b-12-2024", - provider=Provider.COHERE, - display_name="Command R7B", - streaming=True, - parameter_constraints={ - Parameter.TEMPERATURE: Range(min=0.0, max=1.0, step=0.01), - Parameter.MAX_TOKENS: Range(min=1, max=4096, step=1), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), - # thinking_budget: Support unclear, omit constraint for now - }, - ), -] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/streaming.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/streaming.py deleted file mode 100644 index 79fcdf81..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/streaming.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Cohere streaming for text generation.""" - -from collections.abc import Callable -from typing import Any, Unpack - -from celeste_cohere.chat.streaming import CohereChatStream - -from celeste.types import StructuredOutput -from celeste_text_generation.io import ( - TextGenerationChunk, - TextGenerationFinishReason, - TextGenerationOutput, - TextGenerationUsage, -) -from celeste_text_generation.parameters import TextGenerationParameters -from celeste_text_generation.streaming import TextGenerationStream - - -class CohereTextGenerationStream(CohereChatStream, TextGenerationStream): - """Cohere streaming for text generation.""" - - def __init__( - self, - sse_iterator: Any, # noqa: ANN401 - transform_output: Callable[..., StructuredOutput], - **parameters: Unpack[TextGenerationParameters], - ) -> None: - """Initialize stream with output transformation support.""" - super().__init__(sse_iterator, **parameters) - self._transform_output = transform_output - - def _parse_chunk(self, event: dict[str, Any]) -> TextGenerationChunk | None: - """Parse SSE event into typed Chunk.""" - raw = super()._parse_chunk(event) - if not raw: - return None - - usage = TextGenerationUsage(**raw["usage"]) if raw["usage"] else None - finish_reason = ( - TextGenerationFinishReason(reason=raw["finish_reason"]) - if raw["finish_reason"] - else None - ) - - return TextGenerationChunk( - content=raw["content"], - finish_reason=finish_reason, - usage=usage, - metadata={"raw_event": raw["raw_event"]}, - ) - - def _parse_usage(self, chunks: list[TextGenerationChunk]) -> TextGenerationUsage: - """Extract usage from final chunk.""" - for chunk in reversed(chunks): - if chunk.usage: - return chunk.usage - return TextGenerationUsage() - - def _parse_output( - self, - chunks: list[TextGenerationChunk], - **parameters: Unpack[TextGenerationParameters], - ) -> TextGenerationOutput: - """Assemble chunks into final output.""" - content_chunks = [chunk for chunk in chunks if chunk.content] - content = "".join(chunk.content for chunk in content_chunks) - content = self._transform_output(content, **parameters) - - usage = self._parse_usage(chunks) - finish_reason = chunks[-1].finish_reason if chunks else None - - raw_events = [ - c.metadata["raw_event"] - for c in chunks - if c.metadata.get("raw_event", {}).get("type") - in ("message-end", "stream-end") - ] - - return TextGenerationOutput( - content=content, - usage=usage, - finish_reason=finish_reason, - metadata={"raw_response": raw_events}, - ) - - -__all__ = ["CohereTextGenerationStream"] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/google/__init__.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/google/__init__.py deleted file mode 100644 index c6db4d61..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/google/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Google provider for text generation.""" - -from .client import GoogleTextGenerationClient -from .models import MODELS -from .streaming import GoogleTextGenerationStream - -__all__ = ["MODELS", "GoogleTextGenerationClient", "GoogleTextGenerationStream"] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/google/client.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/google/client.py deleted file mode 100644 index 58558111..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/google/client.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Google client implementation for text generation.""" - -from typing import Any, Unpack - -from celeste_google.generate_content.client import GoogleGenerateContentClient - -from celeste.parameters import ParameterMapper -from celeste.types import StructuredOutput -from celeste_text_generation.client import TextGenerationClient -from celeste_text_generation.io import ( - TextGenerationFinishReason, - TextGenerationInput, - TextGenerationUsage, -) -from celeste_text_generation.parameters import TextGenerationParameters - -from .parameters import GOOGLE_PARAMETER_MAPPERS -from .streaming import GoogleTextGenerationStream - - -class GoogleTextGenerationClient(GoogleGenerateContentClient, TextGenerationClient): - """Google client for text generation.""" - - @classmethod - def parameter_mappers(cls) -> list[ParameterMapper]: - return GOOGLE_PARAMETER_MAPPERS - - def _init_request(self, inputs: TextGenerationInput) -> dict[str, Any]: - """Initialize request from Google contents array format.""" - return { - "contents": [ - { - "role": "user", - "parts": [{"text": inputs.prompt}], - } - ] - } - - def _parse_usage(self, response_data: dict[str, Any]) -> TextGenerationUsage: - """Parse usage from response.""" - usage = super()._parse_usage(response_data) - return TextGenerationUsage(**usage) - - def _parse_content( - self, - response_data: dict[str, Any], - **parameters: Unpack[TextGenerationParameters], - ) -> StructuredOutput: - """Parse content from response.""" - candidates = super()._parse_content(response_data) - parts = candidates[0].get("content", {}).get("parts", []) - text = parts[0].get("text") if parts else "" - return self._transform_output(text or "", **parameters) - - def _parse_finish_reason( - self, response_data: dict[str, Any] - ) -> TextGenerationFinishReason: - """Parse finish reason from response.""" - finish_reason = super()._parse_finish_reason(response_data) - return TextGenerationFinishReason(reason=finish_reason.reason) - - def _stream_class(self) -> type[GoogleTextGenerationStream]: - """Return the Stream class for this client.""" - return GoogleTextGenerationStream - - -__all__ = ["GoogleTextGenerationClient"] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/google/models.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/google/models.py deleted file mode 100644 index a18c0910..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/google/models.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Google models for text generation.""" - -from celeste import Model, Provider -from celeste.constraints import Choice, Range, Schema -from celeste.core import Parameter -from celeste_text_generation.parameters import TextGenerationParameter - -MODELS: list[Model] = [ - Model( - id="gemini-2.5-flash", - provider=Provider.GOOGLE, - display_name="Gemini 2.5 Flash", - streaming=True, - parameter_constraints={ - Parameter.TEMPERATURE: Range(min=0.0, max=2.0), - Parameter.MAX_TOKENS: Range(min=1, max=65536), - # Flash: allows -1 (dynamic), 0 (disable), or >= 0 - TextGenerationParameter.THINKING_BUDGET: Range(min=-1, max=24576), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), - }, - ), - Model( - id="gemini-2.5-flash-lite", - provider=Provider.GOOGLE, - display_name="Gemini 2.5 Flash Lite", - streaming=True, - parameter_constraints={ - Parameter.TEMPERATURE: Range(min=0.0, max=2.0), - Parameter.MAX_TOKENS: Range(min=1, max=65536), - # Flash Lite: allows -1 (dynamic), 0 (disable), or >= 512 - TextGenerationParameter.THINKING_BUDGET: Range( - min=512, max=24576, special_values=[-1, 0] - ), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), - }, - ), - Model( - id="gemini-2.5-pro", - provider=Provider.GOOGLE, - display_name="Gemini 2.5 Pro", - streaming=True, - parameter_constraints={ - Parameter.TEMPERATURE: Range(min=0.0, max=2.0), - Parameter.MAX_TOKENS: Range(min=1, max=65536), - # Pro: allows -1 (dynamic) or >= 128 (cannot use 0) - TextGenerationParameter.THINKING_BUDGET: Range( - min=128, max=32768, special_values=[-1] - ), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), - }, - ), - Model( - id="gemini-3-pro-preview", - provider=Provider.GOOGLE, - display_name="Gemini 3 Pro", - streaming=True, - parameter_constraints={ - Parameter.TEMPERATURE: Range(min=0.0, max=2.0), - Parameter.MAX_TOKENS: Range(min=1, max=65536), - TextGenerationParameter.THINKING_LEVEL: Choice(options=["low", "high"]), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), - }, - ), - Model( - id="gemini-3-flash-preview", - provider=Provider.GOOGLE, - display_name="Gemini 3 Flash", - streaming=True, - parameter_constraints={ - Parameter.TEMPERATURE: Range(min=0.0, max=2.0), - Parameter.MAX_TOKENS: Range(min=1, max=65536), - TextGenerationParameter.THINKING_LEVEL: Choice(options=["low", "high"]), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), - }, - ), -] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/google/parameters.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/google/parameters.py deleted file mode 100644 index afe79fcf..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/google/parameters.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Google Gemini parameter mappers for text generation.""" - -from celeste_google.generate_content.parameters import ( - MaxOutputTokensMapper as _MaxOutputTokensMapper, -) -from celeste_google.generate_content.parameters import ( - OutputSchemaMapper as _OutputSchemaMapper, -) -from celeste_google.generate_content.parameters import ( - TemperatureMapper as _TemperatureMapper, -) -from celeste_google.generate_content.parameters import ( - ThinkingBudgetMapper as _ThinkingBudgetMapper, -) -from celeste_google.generate_content.parameters import ( - ThinkingLevelMapper as _ThinkingLevelMapper, -) - -from celeste.core import Parameter -from celeste.parameters import ParameterMapper -from celeste_text_generation.parameters import TextGenerationParameter - - -class TemperatureMapper(_TemperatureMapper): - name = Parameter.TEMPERATURE - - -class MaxTokensMapper(_MaxOutputTokensMapper): - name = Parameter.MAX_TOKENS - - -class ThinkingBudgetMapper(_ThinkingBudgetMapper): - name = TextGenerationParameter.THINKING_BUDGET - - -class ThinkingLevelMapper(_ThinkingLevelMapper): - name = TextGenerationParameter.THINKING_LEVEL - - -class OutputSchemaMapper(_OutputSchemaMapper): - name = TextGenerationParameter.OUTPUT_SCHEMA - - -GOOGLE_PARAMETER_MAPPERS: list[ParameterMapper] = [ - TemperatureMapper(), - MaxTokensMapper(), - ThinkingBudgetMapper(), - ThinkingLevelMapper(), - OutputSchemaMapper(), -] - -__all__ = ["GOOGLE_PARAMETER_MAPPERS"] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/google/streaming.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/google/streaming.py deleted file mode 100644 index 031f53bd..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/google/streaming.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Google streaming for text generation.""" - -from collections.abc import Callable -from typing import Any, Unpack - -from celeste_google.generate_content.streaming import GoogleGenerateContentStream - -from celeste.types import StructuredOutput -from celeste_text_generation.io import ( - TextGenerationChunk, - TextGenerationFinishReason, - TextGenerationOutput, - TextGenerationUsage, -) -from celeste_text_generation.parameters import TextGenerationParameters -from celeste_text_generation.streaming import TextGenerationStream - - -class GoogleTextGenerationStream(GoogleGenerateContentStream, TextGenerationStream): - """Google streaming for text generation.""" - - def __init__( - self, - sse_iterator: Any, # noqa: ANN401 - transform_output: Callable[..., StructuredOutput], - **parameters: Unpack[TextGenerationParameters], - ) -> None: - """Initialize stream with output transformation support.""" - super().__init__(sse_iterator, **parameters) - self._transform_output = transform_output - - def _parse_chunk(self, event: dict[str, Any]) -> TextGenerationChunk | None: - """Parse SSE event into typed Chunk.""" - raw = super()._parse_chunk(event) - if not raw: - return None - - usage = TextGenerationUsage(**raw["usage"]) if raw["usage"] else None - finish_reason = ( - TextGenerationFinishReason(reason=raw["finish_reason"]) - if raw["finish_reason"] - else None - ) - - return TextGenerationChunk( - content=raw["content"], - finish_reason=finish_reason, - usage=usage, - metadata={"raw_event": raw["raw_event"]}, - ) - - def _parse_usage(self, chunks: list[TextGenerationChunk]) -> TextGenerationUsage: - """Extract usage from final chunk.""" - for chunk in reversed(chunks): - if chunk.usage: - return chunk.usage - return TextGenerationUsage() - - def _parse_output( - self, - chunks: list[TextGenerationChunk], - **parameters: Unpack[TextGenerationParameters], - ) -> TextGenerationOutput: - """Assemble chunks into final output.""" - content = "".join(chunk.content for chunk in chunks) - content = self._transform_output(content, **parameters) - - usage = self._parse_usage(chunks) - finish_reason = chunks[-1].finish_reason if chunks else None - - raw_events = [ - c.metadata["raw_event"] for c in chunks if c.metadata.get("raw_event") - ] - - return TextGenerationOutput( - content=content, - usage=usage, - finish_reason=finish_reason, - metadata={"raw_response": raw_events}, - ) - - -__all__ = ["GoogleTextGenerationStream"] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/mistral/__init__.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/mistral/__init__.py deleted file mode 100644 index 2f8e8893..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/mistral/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Mistral provider for text generation.""" - -from .client import MistralTextGenerationClient -from .models import MODELS -from .streaming import MistralTextGenerationStream - -__all__ = ["MODELS", "MistralTextGenerationClient", "MistralTextGenerationStream"] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/mistral/client.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/mistral/client.py deleted file mode 100644 index cf625b28..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/mistral/client.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Mistral client implementation for text generation.""" - -from typing import Any, Unpack - -from celeste_mistral.chat.client import MistralChatClient - -from celeste.parameters import ParameterMapper -from celeste.types import StructuredOutput -from celeste_text_generation.client import TextGenerationClient -from celeste_text_generation.io import ( - TextGenerationFinishReason, - TextGenerationInput, - TextGenerationUsage, -) -from celeste_text_generation.parameters import TextGenerationParameters - -from .parameters import MISTRAL_PARAMETER_MAPPERS -from .streaming import MistralTextGenerationStream - - -class MistralTextGenerationClient(MistralChatClient, TextGenerationClient): - """Mistral client for text generation.""" - - @classmethod - def parameter_mappers(cls) -> list[ParameterMapper]: - return MISTRAL_PARAMETER_MAPPERS - - def _init_request(self, inputs: TextGenerationInput) -> dict[str, Any]: - """Initialize request from Mistral messages array format.""" - messages = [ - { - "role": "user", - "content": inputs.prompt, - } - ] - - return {"messages": messages} - - def _parse_usage(self, response_data: dict[str, Any]) -> TextGenerationUsage: - """Parse usage from response.""" - usage = super()._parse_usage(response_data) - return TextGenerationUsage(**usage) - - def _parse_content( - self, - response_data: dict[str, Any], - **parameters: Unpack[TextGenerationParameters], - ) -> StructuredOutput: - """Parse content from response.""" - choices = response_data.get("choices", []) - if not choices: - msg = "No choices in response" - raise ValueError(msg) - - first_choice = choices[0] - message = first_choice.get("message", {}) - content = message.get("content") or "" - - # Handle magistral thinking models that return list content - if isinstance(content, list): - text_parts = [] - for block in content: - if isinstance(block, dict) and block.get("type") == "text": - text_parts.append(block.get("text", "")) - content = "".join(text_parts) - - return self._transform_output(content, **parameters) - - def _parse_finish_reason( - self, response_data: dict[str, Any] - ) -> TextGenerationFinishReason: - """Parse finish reason from response.""" - choices = response_data.get("choices", []) - if not choices: - finish_reason_str = None - else: - first_choice = choices[0] - finish_reason_str = first_choice.get("finish_reason") - return TextGenerationFinishReason(reason=finish_reason_str) - - def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: - """Build metadata dictionary from response data.""" - # Filter content field before calling super - content_fields = {"choices"} - filtered_data = { - k: v for k, v in response_data.items() if k not in content_fields - } - return super()._build_metadata(filtered_data) - - def _stream_class(self) -> type[MistralTextGenerationStream]: - """Return the Stream class for this client.""" - return MistralTextGenerationStream - - -__all__ = ["MistralTextGenerationClient"] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/mistral/streaming.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/mistral/streaming.py deleted file mode 100644 index 47d7d752..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/mistral/streaming.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Mistral streaming for text generation.""" - -from collections.abc import Callable -from typing import Any, Unpack - -from celeste_mistral.chat.streaming import MistralChatStream - -from celeste.types import StructuredOutput -from celeste_text_generation.io import ( - TextGenerationChunk, - TextGenerationFinishReason, - TextGenerationOutput, - TextGenerationUsage, -) -from celeste_text_generation.parameters import TextGenerationParameters -from celeste_text_generation.streaming import TextGenerationStream - - -class MistralTextGenerationStream(MistralChatStream, TextGenerationStream): - """Mistral streaming for text generation.""" - - def __init__( - self, - sse_iterator: Any, # noqa: ANN401 - transform_output: Callable[..., StructuredOutput], - **parameters: Unpack[TextGenerationParameters], - ) -> None: - """Initialize stream with output transformation support.""" - super().__init__(sse_iterator, **parameters) - self._transform_output = transform_output - - def _parse_chunk(self, event: dict[str, Any]) -> TextGenerationChunk | None: - """Parse SSE event into typed Chunk.""" - raw = super()._parse_chunk(event) - if not raw: - return None - - usage = TextGenerationUsage(**raw["usage"]) if raw["usage"] else None - finish_reason = ( - TextGenerationFinishReason(reason=raw["finish_reason"]) - if raw["finish_reason"] - else None - ) - - return TextGenerationChunk( - content=raw["content"], - finish_reason=finish_reason, - usage=usage, - metadata={"raw_event": raw["raw_event"]}, - ) - - def _parse_usage(self, chunks: list[TextGenerationChunk]) -> TextGenerationUsage: - """Extract usage from final chunk.""" - for chunk in reversed(chunks): - if chunk.usage: - return chunk.usage - return TextGenerationUsage() - - def _parse_output( - self, - chunks: list[TextGenerationChunk], - **parameters: Unpack[TextGenerationParameters], - ) -> TextGenerationOutput: - """Assemble chunks into final output.""" - content_chunks = [chunk for chunk in chunks if chunk.content] - content = "".join(chunk.content for chunk in content_chunks) - content = self._transform_output(content, **parameters) - - usage = self._parse_usage(chunks) - finish_reason = chunks[-1].finish_reason if chunks else None - - raw_response = None - for chunk in reversed(chunks): - raw_event = chunk.metadata.get("raw_event") - if raw_event and raw_event.get("usage"): - raw_response = raw_event - break - - return TextGenerationOutput( - content=content, - usage=usage, - finish_reason=finish_reason, - metadata={"raw_response": raw_response}, - ) - - -__all__ = ["MistralTextGenerationStream"] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/__init__.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/__init__.py deleted file mode 100644 index 6e0b6d99..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""OpenAI provider for text generation.""" - -from .client import OpenAITextGenerationClient -from .models import MODELS -from .streaming import OpenAITextGenerationStream - -__all__ = ["MODELS", "OpenAITextGenerationClient", "OpenAITextGenerationStream"] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/client.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/client.py deleted file mode 100644 index e66272da..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/client.py +++ /dev/null @@ -1,65 +0,0 @@ -"""OpenAI client implementation for text generation.""" - -from typing import Any, Unpack - -from celeste_openai.responses.client import OpenAIResponsesClient - -from celeste.parameters import ParameterMapper -from celeste.types import StructuredOutput -from celeste_text_generation.client import TextGenerationClient -from celeste_text_generation.io import ( - TextGenerationFinishReason, - TextGenerationInput, - TextGenerationUsage, -) -from celeste_text_generation.parameters import TextGenerationParameters - -from .parameters import OPENAI_PARAMETER_MAPPERS -from .streaming import OpenAITextGenerationStream - - -class OpenAITextGenerationClient(OpenAIResponsesClient, TextGenerationClient): - """OpenAI client for text generation.""" - - @classmethod - def parameter_mappers(cls) -> list[ParameterMapper]: - return OPENAI_PARAMETER_MAPPERS - - def _init_request(self, inputs: TextGenerationInput) -> dict[str, Any]: - """Initialize request from OpenAI Responses API format.""" - return {"input": inputs.prompt} - - def _parse_usage(self, response_data: dict[str, Any]) -> TextGenerationUsage: - """Parse usage from response.""" - usage = super()._parse_usage(response_data) - return TextGenerationUsage(**usage) - - def _parse_content( - self, - response_data: dict[str, Any], - **parameters: Unpack[TextGenerationParameters], - ) -> StructuredOutput: - """Parse content from response.""" - output = super()._parse_content(response_data) # Raw output array - # Find message item and extract text - for item in output: - if item.get("type") == "message": - for part in item.get("content", []): - if part.get("type") == "output_text": - text = part.get("text") or "" - return self._transform_output(text, **parameters) - return "" - - def _parse_finish_reason( - self, response_data: dict[str, Any] - ) -> TextGenerationFinishReason: - """Parse finish reason from response.""" - finish_reason = super()._parse_finish_reason(response_data) - return TextGenerationFinishReason(reason=finish_reason.reason) - - def _stream_class(self) -> type[OpenAITextGenerationStream]: - """Return the Stream class for this client.""" - return OpenAITextGenerationStream - - -__all__ = ["OpenAITextGenerationClient"] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/parameters.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/parameters.py deleted file mode 100644 index b86a0068..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/parameters.py +++ /dev/null @@ -1,52 +0,0 @@ -"""OpenAI Responses parameter mappers for text generation.""" - -from celeste_openai.responses.parameters import ( - MaxTokensMapper as _MaxTokensMapper, -) -from celeste_openai.responses.parameters import ( - OutputSchemaMapper as _OutputSchemaMapper, -) -from celeste_openai.responses.parameters import ( - ReasoningEffortMapper as _ReasoningEffortMapper, -) -from celeste_openai.responses.parameters import ( - TemperatureMapper as _TemperatureMapper, -) -from celeste_openai.responses.parameters import ( - VerbosityMapper as _VerbosityMapper, -) - -from celeste.core import Parameter -from celeste.parameters import ParameterMapper -from celeste_text_generation.parameters import TextGenerationParameter - - -class TemperatureMapper(_TemperatureMapper): - name = Parameter.TEMPERATURE - - -class MaxTokensMapper(_MaxTokensMapper): - name = Parameter.MAX_TOKENS - - -class ThinkingBudgetMapper(_ReasoningEffortMapper): - name = TextGenerationParameter.THINKING_BUDGET - - -class VerbosityMapper(_VerbosityMapper): - name = TextGenerationParameter.VERBOSITY - - -class OutputSchemaMapper(_OutputSchemaMapper): - name = TextGenerationParameter.OUTPUT_SCHEMA - - -OPENAI_PARAMETER_MAPPERS: list[ParameterMapper] = [ - TemperatureMapper(), - MaxTokensMapper(), - ThinkingBudgetMapper(), - VerbosityMapper(), - OutputSchemaMapper(), -] - -__all__ = ["OPENAI_PARAMETER_MAPPERS"] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/streaming.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/streaming.py deleted file mode 100644 index 58952e82..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/streaming.py +++ /dev/null @@ -1,85 +0,0 @@ -"""OpenAI streaming for text generation.""" - -from collections.abc import Callable -from typing import Any, Unpack - -from celeste_openai.responses.streaming import OpenAIResponsesStream - -from celeste.types import StructuredOutput -from celeste_text_generation.io import ( - TextGenerationChunk, - TextGenerationFinishReason, - TextGenerationOutput, - TextGenerationUsage, -) -from celeste_text_generation.parameters import TextGenerationParameters -from celeste_text_generation.streaming import TextGenerationStream - - -class OpenAITextGenerationStream(OpenAIResponsesStream, TextGenerationStream): - """OpenAI streaming for text generation.""" - - def __init__( - self, - sse_iterator: Any, # noqa: ANN401 - transform_output: Callable[..., StructuredOutput], - **parameters: Unpack[TextGenerationParameters], - ) -> None: - """Initialize stream.""" - super().__init__(sse_iterator, **parameters) - self._transform_output = transform_output - - def _parse_chunk(self, event: dict[str, Any]) -> TextGenerationChunk | None: - """Parse SSE event into typed Chunk.""" - raw = super()._parse_chunk(event) - if not raw: - return None - - usage = TextGenerationUsage(**raw["usage"]) if raw["usage"] else None - finish_reason = ( - TextGenerationFinishReason(reason=raw["finish_reason"]) - if raw["finish_reason"] - else None - ) - - return TextGenerationChunk( - content=raw["content"], - finish_reason=finish_reason, - usage=usage, - metadata={"raw_event": raw["raw_event"]}, - ) - - def _parse_usage(self, chunks: list[TextGenerationChunk]) -> TextGenerationUsage: - """Extract usage from final chunk.""" - for chunk in reversed(chunks): - if chunk.usage: - return chunk.usage - return TextGenerationUsage() - - def _parse_output( - self, - chunks: list[TextGenerationChunk], - **parameters: Unpack[TextGenerationParameters], - ) -> TextGenerationOutput: - """Assemble chunks into final output.""" - content = "".join(chunk.content for chunk in chunks) - content = self._transform_output(content, **parameters) - usage = self._parse_usage(chunks) - finish_reason = chunks[-1].finish_reason if chunks else None - - raw_response = None - for chunk in reversed(chunks): - raw_event = chunk.metadata.get("raw_event", {}) - if raw_event.get("type") == "response.completed": - raw_response = raw_event.get("response") - break - - return TextGenerationOutput( - content=content, - usage=usage, - finish_reason=finish_reason, - metadata={"raw_response": raw_response}, - ) - - -__all__ = ["OpenAITextGenerationStream"] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/__init__.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/__init__.py deleted file mode 100644 index 61c3ce1e..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""XAI provider for text generation.""" - -from .client import XAITextGenerationClient -from .models import MODELS -from .streaming import XAITextGenerationStream - -__all__ = ["MODELS", "XAITextGenerationClient", "XAITextGenerationStream"] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/client.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/client.py deleted file mode 100644 index 994d68d4..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/client.py +++ /dev/null @@ -1,65 +0,0 @@ -"""XAI client implementation for text generation.""" - -from typing import Any, Unpack - -from celeste_xai.responses.client import XAIResponsesClient - -from celeste.parameters import ParameterMapper -from celeste.types import StructuredOutput -from celeste_text_generation.client import TextGenerationClient -from celeste_text_generation.io import ( - TextGenerationFinishReason, - TextGenerationInput, - TextGenerationUsage, -) -from celeste_text_generation.parameters import TextGenerationParameters - -from .parameters import XAI_PARAMETER_MAPPERS -from .streaming import XAITextGenerationStream - - -class XAITextGenerationClient(XAIResponsesClient, TextGenerationClient): - """XAI client for text generation.""" - - @classmethod - def parameter_mappers(cls) -> list[ParameterMapper]: - return XAI_PARAMETER_MAPPERS - - def _init_request(self, inputs: TextGenerationInput) -> dict[str, Any]: - """Initialize request from XAI Responses API format.""" - return {"input": inputs.prompt} - - def _parse_usage(self, response_data: dict[str, Any]) -> TextGenerationUsage: - """Parse usage from response.""" - usage = super()._parse_usage(response_data) - return TextGenerationUsage(**usage) - - def _parse_content( - self, - response_data: dict[str, Any], - **parameters: Unpack[TextGenerationParameters], - ) -> StructuredOutput: - """Parse content from response.""" - output = super()._parse_content(response_data) # Raw output array - # Find message item and extract text - for item in output: - if item.get("type") == "message": - for part in item.get("content", []): - if part.get("type") == "output_text": - text = part.get("text") or "" - return self._transform_output(text, **parameters) - return "" - - def _parse_finish_reason( - self, response_data: dict[str, Any] - ) -> TextGenerationFinishReason: - """Parse finish reason from response.""" - finish_reason = super()._parse_finish_reason(response_data) - return TextGenerationFinishReason(reason=finish_reason.reason) - - def _stream_class(self) -> type[XAITextGenerationStream]: - """Return the Stream class for this client.""" - return XAITextGenerationStream - - -__all__ = ["XAITextGenerationClient"] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/models.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/models.py deleted file mode 100644 index 449e66e6..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/models.py +++ /dev/null @@ -1,76 +0,0 @@ -"""XAI models for text generation.""" - -from celeste import Model, Provider -from celeste.constraints import Choice, Range, Schema -from celeste.core import Parameter -from celeste_text_generation.parameters import TextGenerationParameter - -MODELS: list[Model] = [ - Model( - id="grok-4-1-fast-reasoning", - provider=Provider.XAI, - display_name="Grok 4.1 Fast Reasoning", - streaming=True, - parameter_constraints={ - Parameter.TEMPERATURE: Range(min=0.0, max=2.0), - Parameter.MAX_TOKENS: Range(min=1, max=30000), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), - }, - ), - Model( - id="grok-4-1-fast-non-reasoning", - provider=Provider.XAI, - display_name="Grok 4.1 Fast Non-Reasoning", - streaming=True, - parameter_constraints={ - Parameter.TEMPERATURE: Range(min=0.0, max=2.0), - Parameter.MAX_TOKENS: Range(min=1, max=30000), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), - }, - ), - Model( - id="grok-4-fast-reasoning", - provider=Provider.XAI, - display_name="Grok 4 Fast Reasoning", - streaming=True, - parameter_constraints={ - Parameter.TEMPERATURE: Range(min=0.0, max=2.0), - Parameter.MAX_TOKENS: Range(min=1, max=30000), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), - }, - ), - Model( - id="grok-4-fast-non-reasoning", - provider=Provider.XAI, - display_name="Grok 4 Fast Non-Reasoning", - streaming=True, - parameter_constraints={ - Parameter.TEMPERATURE: Range(min=0.0, max=2.0), - Parameter.MAX_TOKENS: Range(min=1, max=30000), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), - }, - ), - Model( - id="grok-4-0709", - provider=Provider.XAI, - display_name="Grok 4", - streaming=True, - parameter_constraints={ - Parameter.TEMPERATURE: Range(min=0.0, max=2.0), - Parameter.MAX_TOKENS: Range(min=1, max=64000), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), - }, - ), - Model( - id="grok-3-mini", - provider=Provider.XAI, - display_name="Grok 3 Mini", - streaming=True, - parameter_constraints={ - Parameter.TEMPERATURE: Range(min=0.0, max=2.0), - Parameter.MAX_TOKENS: Range(min=1, max=16000), - TextGenerationParameter.THINKING_LEVEL: Choice(options=["low", "high"]), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), - }, - ), -] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/parameters.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/parameters.py deleted file mode 100644 index 7bf92a96..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/parameters.py +++ /dev/null @@ -1,44 +0,0 @@ -"""XAI Responses parameter mappers for text generation.""" - -from celeste_xai.responses.parameters import ( - MaxTokensMapper as _MaxTokensMapper, -) -from celeste_xai.responses.parameters import ( - OutputSchemaMapper as _OutputSchemaMapper, -) -from celeste_xai.responses.parameters import ( - ReasoningEffortMapper as _ReasoningEffortMapper, -) -from celeste_xai.responses.parameters import ( - TemperatureMapper as _TemperatureMapper, -) - -from celeste.core import Parameter -from celeste.parameters import ParameterMapper -from celeste_text_generation.parameters import TextGenerationParameter - - -class TemperatureMapper(_TemperatureMapper): - name = Parameter.TEMPERATURE - - -class MaxTokensMapper(_MaxTokensMapper): - name = Parameter.MAX_TOKENS - - -class ThinkingBudgetMapper(_ReasoningEffortMapper): - name = TextGenerationParameter.THINKING_BUDGET - - -class OutputSchemaMapper(_OutputSchemaMapper): - name = TextGenerationParameter.OUTPUT_SCHEMA - - -XAI_PARAMETER_MAPPERS: list[ParameterMapper] = [ - TemperatureMapper(), - MaxTokensMapper(), - ThinkingBudgetMapper(), - OutputSchemaMapper(), -] - -__all__ = ["XAI_PARAMETER_MAPPERS"] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/streaming.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/streaming.py deleted file mode 100644 index 5b003b97..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/streaming.py +++ /dev/null @@ -1,85 +0,0 @@ -"""XAI streaming for text generation.""" - -from collections.abc import Callable -from typing import Any, Unpack - -from celeste_xai.responses.streaming import XAIResponsesStream - -from celeste.types import StructuredOutput -from celeste_text_generation.io import ( - TextGenerationChunk, - TextGenerationFinishReason, - TextGenerationOutput, - TextGenerationUsage, -) -from celeste_text_generation.parameters import TextGenerationParameters -from celeste_text_generation.streaming import TextGenerationStream - - -class XAITextGenerationStream(XAIResponsesStream, TextGenerationStream): - """XAI streaming for text generation.""" - - def __init__( - self, - sse_iterator: Any, # noqa: ANN401 - transform_output: Callable[..., StructuredOutput], - **parameters: Unpack[TextGenerationParameters], - ) -> None: - """Initialize stream.""" - super().__init__(sse_iterator, **parameters) - self._transform_output = transform_output - - def _parse_chunk(self, event: dict[str, Any]) -> TextGenerationChunk | None: - """Parse SSE event into typed Chunk.""" - raw = super()._parse_chunk(event) - if not raw: - return None - - usage = TextGenerationUsage(**raw["usage"]) if raw["usage"] else None - finish_reason = ( - TextGenerationFinishReason(reason=raw["finish_reason"]) - if raw["finish_reason"] - else None - ) - - return TextGenerationChunk( - content=raw["content"], - finish_reason=finish_reason, - usage=usage, - metadata={"raw_event": raw["raw_event"]}, - ) - - def _parse_usage(self, chunks: list[TextGenerationChunk]) -> TextGenerationUsage: - """Extract usage from final chunk.""" - for chunk in reversed(chunks): - if chunk.usage: - return chunk.usage - return TextGenerationUsage() - - def _parse_output( - self, - chunks: list[TextGenerationChunk], - **parameters: Unpack[TextGenerationParameters], - ) -> TextGenerationOutput: - """Assemble chunks into final output.""" - content = "".join(chunk.content for chunk in chunks) - content = self._transform_output(content, **parameters) - usage = self._parse_usage(chunks) - finish_reason = chunks[-1].finish_reason if chunks else None - - raw_response = None - for chunk in reversed(chunks): - raw_event = chunk.metadata.get("raw_event", {}) - if raw_event.get("type") == "response.completed": - raw_response = raw_event.get("response") - break - - return TextGenerationOutput( - content=content, - usage=usage, - finish_reason=finish_reason, - metadata={"raw_response": raw_response}, - ) - - -__all__ = ["XAITextGenerationStream"] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/py.typed b/packages/capabilities/text-generation/src/celeste_text_generation/py.typed deleted file mode 100644 index 321d0ae1..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/py.typed +++ /dev/null @@ -1 +0,0 @@ -# Marker file for PEP 561 - this package supports type checking diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/streaming.py b/packages/capabilities/text-generation/src/celeste_text_generation/streaming.py deleted file mode 100644 index 085422c4..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/streaming.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Streaming for text generation.""" - -from abc import abstractmethod -from typing import Any, Unpack - -from celeste.streaming import Stream -from celeste_text_generation.io import ( - TextGenerationChunk, - TextGenerationOutput, - TextGenerationUsage, -) -from celeste_text_generation.parameters import TextGenerationParameters - - -class TextGenerationStream( - Stream[TextGenerationOutput, TextGenerationParameters, TextGenerationChunk] -): - """Streaming for text generation.""" - - @abstractmethod - def _parse_chunk(self, event: dict[str, Any]) -> TextGenerationChunk | None: - """Parse SSE event into Chunk (provider-specific).""" - - def _parse_output( # type: ignore[override] - self, - chunks: list[TextGenerationChunk], - **parameters: Unpack[TextGenerationParameters], - ) -> TextGenerationOutput: - """Assemble chunks into final output.""" - content = "".join(chunk.content for chunk in chunks) - usage = self._parse_usage(chunks) - finish_reason = chunks[-1].finish_reason if chunks else None - - return TextGenerationOutput( - content=content, - usage=usage, - finish_reason=finish_reason, - metadata={}, - ) - - @abstractmethod - def _parse_usage(self, chunks: list[TextGenerationChunk]) -> TextGenerationUsage: - """Parse usage from chunks (provider-specific).""" - - -__all__ = ["TextGenerationStream"] diff --git a/packages/capabilities/text-generation/tests/integration_tests/conftest.py b/packages/capabilities/text-generation/tests/integration_tests/conftest.py deleted file mode 100644 index d06d892f..00000000 --- a/packages/capabilities/text-generation/tests/integration_tests/conftest.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Pytest configuration and fixtures for integration tests.""" - -from collections.abc import AsyncGenerator - -import pytest_asyncio - -from celeste.http import close_all_http_clients - - -@pytest_asyncio.fixture(autouse=True) -async def cleanup_http_clients() -> AsyncGenerator[None, None]: - """Ensure HTTP clients are closed after each test. - - This fixture runs automatically after each test to ensure HTTP clients - are properly closed before pytest-asyncio closes the event loop. - This prevents "Event loop is closed" errors when using pytest-xdist. - """ - yield - await close_all_http_clients() diff --git a/packages/capabilities/text-generation/tests/integration_tests/test_text_generation/__init__.py b/packages/capabilities/text-generation/tests/integration_tests/test_text_generation/__init__.py deleted file mode 100644 index 432e7b47..00000000 --- a/packages/capabilities/text-generation/tests/integration_tests/test_text_generation/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Text generation integration test module.""" diff --git a/packages/capabilities/text-generation/tests/integration_tests/test_text_generation/test_generate.py b/packages/capabilities/text-generation/tests/integration_tests/test_text_generation/test_generate.py deleted file mode 100644 index 2a06f34d..00000000 --- a/packages/capabilities/text-generation/tests/integration_tests/test_text_generation/test_generate.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Integration tests for text generation across all providers.""" - -import pytest - -from celeste import Capability, Provider, create_client - - -@pytest.mark.parametrize( - ("provider", "model", "parameters"), - [ - (Provider.OPENAI, "gpt-3.5-turbo", {}), - (Provider.ANTHROPIC, "claude-haiku-4-5", {}), - (Provider.GOOGLE, "gemini-2.5-flash-lite", {"thinking_budget": 0}), - (Provider.MISTRAL, "mistral-tiny", {}), - (Provider.COHERE, "command-a-03-2025", {}), - (Provider.XAI, "grok-3-mini", {}), - ], -) -@pytest.mark.integration -@pytest.mark.asyncio -async def test_generate(provider: Provider, model: str, parameters: dict) -> None: - """Test text generation with max_tokens parameter across all providers. - - This test demonstrates that the unified API works identically across - all providers using the same code - proving the abstraction value. - """ - # Import here to avoid circular import during pytest collection - from celeste_text_generation import TextGenerationOutput, TextGenerationUsage - - # Arrange - client = create_client( - capability=Capability.TEXT_GENERATION, - provider=provider, - model=model, - ) - prompt = "Hi" - max_tokens = 30 - - # Act - response = await client.generate( - prompt=prompt, - max_tokens=max_tokens, - **parameters, - ) - - # Assert - assert isinstance(response, TextGenerationOutput), ( - f"Expected TextGenerationOutput, got {type(response)}" - ) - assert isinstance(response.content, str), ( - f"Expected str content, got {type(response.content)}" - ) - assert len(response.content) > 0, f"Content is empty: {response.content!r}" - - # Validate usage metrics - assert isinstance(response.usage, TextGenerationUsage), ( - f"Expected TextGenerationUsage, got {type(response.usage)}" - ) diff --git a/packages/capabilities/text-generation/tests/integration_tests/test_text_generation/test_stream.py b/packages/capabilities/text-generation/tests/integration_tests/test_text_generation/test_stream.py deleted file mode 100644 index f0a6e760..00000000 --- a/packages/capabilities/text-generation/tests/integration_tests/test_text_generation/test_stream.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Integration tests for text generation streaming across all providers.""" - -import pytest - -from celeste import Capability, Provider, create_client - - -@pytest.mark.parametrize( - ("provider", "model", "parameters"), - [ - (Provider.OPENAI, "gpt-3.5-turbo", {}), - (Provider.ANTHROPIC, "claude-haiku-4-5", {}), - (Provider.GOOGLE, "gemini-2.5-flash-lite", {"thinking_budget": 0}), - (Provider.MISTRAL, "mistral-tiny", {}), - (Provider.COHERE, "command-a-03-2025", {}), - (Provider.XAI, "grok-3-mini", {}), - ], -) -@pytest.mark.integration -@pytest.mark.asyncio -async def test_stream(provider: Provider, model: str, parameters: dict) -> None: - """Test text generation streaming with max_tokens parameter across all providers. - - This test demonstrates that the unified streaming API works identically across - all providers using the same code - proving the abstraction value. - """ - # Import here to avoid circular import during pytest collection - from celeste_text_generation import ( - TextGenerationChunk, - TextGenerationFinishReason, - TextGenerationOutput, - TextGenerationStream, - TextGenerationUsage, - ) - - # Arrange - client = create_client( - capability=Capability.TEXT_GENERATION, - provider=provider, - model=model, - ) - prompt = "Hi" - max_tokens = 30 - - # Act - Create stream - stream = client.stream( - prompt=prompt, - max_tokens=max_tokens, - **parameters, - ) - - # Assert 1: Stream Creation - assert isinstance(stream, TextGenerationStream), ( - f"Expected TextGenerationStream, got {type(stream)}" - ) - - # Act - Iterate chunks - chunks: list[TextGenerationChunk] = [] - async for chunk in stream: - # Assert 2: Chunk Structure - assert isinstance(chunk, TextGenerationChunk), ( - f"Expected TextGenerationChunk, got {type(chunk)}" - ) - assert isinstance(chunk.content, str), ( - f"Expected str content, got {type(chunk.content)}" - ) - if chunk.finish_reason is not None: - assert isinstance(chunk.finish_reason, TextGenerationFinishReason), ( - f"Expected TextGenerationFinishReason, got {type(chunk.finish_reason)}" - ) - if chunk.usage is not None: - assert isinstance(chunk.usage, TextGenerationUsage), ( - f"Expected TextGenerationUsage, got {type(chunk.usage)}" - ) - chunks.append(chunk) - - # Assert 3: Content Accumulation - assert len(chunks) > 0, "No chunks were yielded from stream" - accumulated_content = "".join(chunk.content for chunk in chunks) - assert len(accumulated_content) > 0, f"Accumulated content is empty: {chunks!r}" - - # Assert 4: Final Output - output = stream.output - assert isinstance(output, TextGenerationOutput), ( - f"Expected TextGenerationOutput, got {type(output)}" - ) - assert isinstance(output.content, str), ( - f"Expected str content, got {type(output.content)}" - ) - assert output.content == accumulated_content, ( - f"Output content doesn't match accumulated chunks: " - f"{output.content!r} != {accumulated_content!r}" - ) - - # Assert 5: Usage Metrics - assert isinstance(output.usage, TextGenerationUsage), ( - f"Expected TextGenerationUsage, got {type(output.usage)}" - ) - - # Assert 6: Finish Reason - assert chunks[-1].finish_reason is not None, "Final chunk should have finish_reason" - assert output.finish_reason is not None, ( - "Output should have finish_reason as direct field" - ) - assert isinstance(output.finish_reason, TextGenerationFinishReason), ( - f"Expected TextGenerationFinishReason, got {type(output.finish_reason)}" - ) diff --git a/packages/capabilities/video-generation/README.md b/packages/capabilities/video-generation/README.md deleted file mode 100644 index 276d63a9..00000000 --- a/packages/capabilities/video-generation/README.md +++ /dev/null @@ -1,79 +0,0 @@ -
- -# Celeste Logo Celeste Video Generation - -**Video Generation capability for Celeste AI** - -[![Python](https://img.shields.io/badge/Python-3.12+-blue?style=for-the-badge)](https://www.python.org/) -[![License](https://img.shields.io/badge/License-MIT-yellow?style=for-the-badge)](../../../LICENSE) - -[Quick Start](#-quick-start) • [Documentation](https://withceleste.ai/docs) • [Request Provider](https://github.com/withceleste/celeste-python/issues/new) - -
- ---- - -## 🚀 Quick Start - -```python -from celeste import create_client, Capability, Provider - -client = create_client( - capability=Capability.VIDEO_GENERATION, - provider=Provider.OPENAI, -) - -response = await client.generate(prompt="A cinematic video of a sunset over mountains") -print(response.content) -``` - -**Install:** -```bash -uv add "celeste-ai[video-generation]" -``` - ---- - -## Supported Providers - - -
- -ByteDance -OpenAI -Google - - -**Missing a provider?** [Request it](https://github.com/withceleste/celeste-python/issues/new) – ⚡ **we ship fast**. - -
- ---- - -**Streaming**: ❌ Not Supported - -**Parameters**: See [API Documentation](https://withceleste.ai/docs/api) for full parameter reference. - ---- - -## 🤝 Contributing - -See [CONTRIBUTING.md](../../CONTRIBUTING.md) for guidelines. - -**Request a provider:** [GitHub Issues](https://github.com/withceleste/celeste-python/issues/new) - ---- - -## 📄 License - -MIT 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/packages/capabilities/video-generation/pyproject.toml b/packages/capabilities/video-generation/pyproject.toml deleted file mode 100644 index 2dcbc821..00000000 --- a/packages/capabilities/video-generation/pyproject.toml +++ /dev/null @@ -1,49 +0,0 @@ -[project] -name = "celeste-video-generation" -version = "0.3.7" -description = "Video generation package for Celeste AI. Unified interface for all providers" -authors = [{name = "Kamilbenkirane", email = "kamil@withceleste.ai"}] -readme = "README.md" -license = {text = "MIT"} -requires-python = ">=3.12" -classifiers = [ - "Development Status :: 3 - Alpha", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT 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", "video-generation", "sora", "runway", "openai", "google", "video-ai"] -dependencies = [ - "celeste-ai>=0.3.3", - "celeste-byteplus>=0.3.3", - "celeste-google>=0.3.3", - "celeste-openai>=0.3.3", - "pillow>=10.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" - -[tool.uv.sources] -celeste-ai = { workspace = true } -celeste-byteplus = { workspace = true } -celeste-google = { workspace = true } -celeste-openai = { workspace = true } - -[project.entry-points."celeste.packages"] -video-generation = "celeste_video_generation:register_package" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/celeste_video_generation"] diff --git a/packages/capabilities/video-generation/src/celeste_video_generation/__init__.py b/packages/capabilities/video-generation/src/celeste_video_generation/__init__.py deleted file mode 100644 index ae003bea..00000000 --- a/packages/capabilities/video-generation/src/celeste_video_generation/__init__.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Celeste video generation capability.""" - - -def register_package() -> None: - """Register video generation package (client, models, and input).""" - from celeste.client import register_client - from celeste.core import Capability - from celeste.io import register_input - from celeste.models import register_models - from celeste_video_generation.io import VideoGenerationInput - from celeste_video_generation.models import MODELS - from celeste_video_generation.providers import PROVIDERS - - for provider, client_class in PROVIDERS: - register_client(Capability.VIDEO_GENERATION, provider, client_class) - - register_models(MODELS, capability=Capability.VIDEO_GENERATION) - register_input(Capability.VIDEO_GENERATION, VideoGenerationInput) - - -from celeste_video_generation.io import ( # noqa: E402 - VideoGenerationInput, - VideoGenerationOutput, - VideoGenerationUsage, -) - -__all__ = [ - "VideoGenerationInput", - "VideoGenerationOutput", - "VideoGenerationUsage", - "register_package", -] diff --git a/packages/capabilities/video-generation/src/celeste_video_generation/client.py b/packages/capabilities/video-generation/src/celeste_video_generation/client.py deleted file mode 100644 index 680c2329..00000000 --- a/packages/capabilities/video-generation/src/celeste_video_generation/client.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Base client for video generation.""" - -from abc import abstractmethod -from typing import Any, Unpack - -import httpx - -from celeste.artifacts import VideoArtifact -from celeste.client import Client -from celeste.exceptions import ValidationError -from celeste_video_generation.io import ( - VideoGenerationInput, - VideoGenerationOutput, - VideoGenerationUsage, -) -from celeste_video_generation.parameters import VideoGenerationParameters - - -class VideoGenerationClient( - Client[VideoGenerationInput, VideoGenerationOutput, VideoGenerationParameters] -): - """Client for video generation operations.""" - - @abstractmethod - def _init_request(self, inputs: VideoGenerationInput) -> dict[str, Any]: - """Initialize provider-specific request structure.""" - - @abstractmethod - def _parse_usage(self, response_data: dict[str, Any]) -> VideoGenerationUsage: - """Parse usage information from provider response.""" - - @abstractmethod - def _parse_content( - self, - response_data: dict[str, Any], - **parameters: Unpack[VideoGenerationParameters], - ) -> VideoArtifact: - """Parse content from provider response.""" - - def _create_inputs( - self, *args: str, **parameters: Unpack[VideoGenerationParameters] - ) -> VideoGenerationInput: - """Map positional arguments to Input type.""" - if args: - return VideoGenerationInput(prompt=args[0]) - prompt: str | None = parameters.get("prompt") - if prompt is None: - msg = ( - "prompt is required (either as positional argument or keyword argument)" - ) - raise ValidationError(msg) - return VideoGenerationInput(prompt=prompt) - - @classmethod - def _output_class(cls) -> type[VideoGenerationOutput]: - """Return the Output class for this client.""" - return VideoGenerationOutput - - def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: - """Build metadata dictionary from response data.""" - metadata = super()._build_metadata(response_data) - metadata["raw_response"] = response_data - return metadata - - @abstractmethod - async def _make_request( - self, - request_body: dict[str, Any], - **parameters: Unpack[VideoGenerationParameters], - ) -> httpx.Response: - """Make HTTP request(s) and return response object.""" diff --git a/packages/capabilities/video-generation/src/celeste_video_generation/io.py b/packages/capabilities/video-generation/src/celeste_video_generation/io.py deleted file mode 100644 index 5b7401df..00000000 --- a/packages/capabilities/video-generation/src/celeste_video_generation/io.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Input and output types for video generation.""" - -from pydantic import Field - -from celeste.artifacts import VideoArtifact -from celeste.io import Input, Output, Usage - - -class VideoGenerationInput(Input): - """Input for video generation operations.""" - - prompt: str - - -class VideoGenerationUsage(Usage): - """Video generation usage metrics. - - All fields optional since providers vary. - """ - - total_tokens: int | None = None - billing_units: float | None = None - - -class VideoGenerationOutput(Output[VideoArtifact]): - """Output with VideoArtifact content.""" - - usage: VideoGenerationUsage = Field(default_factory=VideoGenerationUsage) - - -__all__ = [ - "VideoGenerationInput", - "VideoGenerationOutput", - "VideoGenerationUsage", -] diff --git a/packages/capabilities/video-generation/src/celeste_video_generation/models.py b/packages/capabilities/video-generation/src/celeste_video_generation/models.py deleted file mode 100644 index 07d29ee1..00000000 --- a/packages/capabilities/video-generation/src/celeste_video_generation/models.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Model definitions for video generation.""" - -from celeste import Model -from celeste_video_generation.providers.byteplus.models import ( - MODELS as BYTEPLUS_MODELS, -) -from celeste_video_generation.providers.google.models import MODELS as GOOGLE_MODELS -from celeste_video_generation.providers.openai.models import MODELS as OPENAI_MODELS - -MODELS: list[Model] = [ - *BYTEPLUS_MODELS, - *GOOGLE_MODELS, - *OPENAI_MODELS, -] diff --git a/packages/capabilities/video-generation/src/celeste_video_generation/parameters.py b/packages/capabilities/video-generation/src/celeste_video_generation/parameters.py deleted file mode 100644 index c52f8937..00000000 --- a/packages/capabilities/video-generation/src/celeste_video_generation/parameters.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Parameters for video generation.""" - -from enum import StrEnum - -from celeste.artifacts import ImageArtifact -from celeste.parameters import Parameters - - -class VideoGenerationParameter(StrEnum): - """Unified parameter names for video generation capability.""" - - ASPECT_RATIO = "aspect_ratio" - RESOLUTION = "resolution" - DURATION = "duration" - REFERENCE_IMAGES = "reference_images" - FIRST_FRAME = "first_frame" - LAST_FRAME = "last_frame" - - -class VideoGenerationParameters(Parameters): - """Parameters for video generation.""" - - aspect_ratio: str | None - resolution: str | None - duration: int | None - reference_images: list[ImageArtifact] | None - first_frame: ImageArtifact | None - last_frame: ImageArtifact | None diff --git a/packages/capabilities/video-generation/src/celeste_video_generation/providers/__init__.py b/packages/capabilities/video-generation/src/celeste_video_generation/providers/__init__.py deleted file mode 100644 index 2339f556..00000000 --- a/packages/capabilities/video-generation/src/celeste_video_generation/providers/__init__.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Provider implementations for video generation.""" - -from celeste import Client, Provider - -__all__ = ["PROVIDERS"] - - -def _get_providers() -> list[tuple[Provider, type[Client]]]: - """Lazy-load providers.""" - # Import clients directly from .client modules to avoid __init__.py imports - from celeste_video_generation.providers.byteplus.client import ( - BytePlusVideoGenerationClient, - ) - from celeste_video_generation.providers.google.client import ( - GoogleVideoGenerationClient, - ) - from celeste_video_generation.providers.openai.client import ( - OpenAIVideoGenerationClient, - ) - - return [ - (Provider.BYTEPLUS, BytePlusVideoGenerationClient), - (Provider.GOOGLE, GoogleVideoGenerationClient), - (Provider.OPENAI, OpenAIVideoGenerationClient), - ] - - -PROVIDERS: list[tuple[Provider, type[Client]]] = _get_providers() diff --git a/packages/capabilities/video-generation/src/celeste_video_generation/providers/byteplus/README.md b/packages/capabilities/video-generation/src/celeste_video_generation/providers/byteplus/README.md deleted file mode 100644 index a9143804..00000000 --- a/packages/capabilities/video-generation/src/celeste_video_generation/providers/byteplus/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# BytePlus Video Generation Provider - -## Credentials - -**Environment variable:** `BYTEPLUS_API_KEY` - -**Setup:** -1. Register at [console.byteplus.com](https://console.byteplus.com) -2. Activate model in ModelArk section -3. Generate API key with video generation permissions -4. Set environment variable: `export BYTEPLUS_API_KEY="your-key"` - -**Note:** Models must be activated in BytePlus console before use. If you get a 404 error, activate the model or use an Endpoint ID (`ep-xxx`) instead of Model ID. diff --git a/packages/capabilities/video-generation/src/celeste_video_generation/providers/byteplus/__init__.py b/packages/capabilities/video-generation/src/celeste_video_generation/providers/byteplus/__init__.py deleted file mode 100644 index 136cf8a3..00000000 --- a/packages/capabilities/video-generation/src/celeste_video_generation/providers/byteplus/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""BytePlus provider for video generation.""" - -from celeste.core import Provider - -from .client import BytePlusVideoGenerationClient - -__all__ = ["PROVIDERS", "BytePlusVideoGenerationClient"] - -PROVIDERS: list[tuple[Provider, type[BytePlusVideoGenerationClient]]] = [ - (Provider.BYTEPLUS, BytePlusVideoGenerationClient), -] diff --git a/packages/capabilities/video-generation/src/celeste_video_generation/providers/byteplus/client.py b/packages/capabilities/video-generation/src/celeste_video_generation/providers/byteplus/client.py deleted file mode 100644 index c23401a8..00000000 --- a/packages/capabilities/video-generation/src/celeste_video_generation/providers/byteplus/client.py +++ /dev/null @@ -1,168 +0,0 @@ -"""BytePlus client implementation for video generation.""" - -import base64 -import logging -from typing import Any, Unpack - -from celeste_byteplus.videos.client import BytePlusVideosClient - -from celeste.artifacts import ImageArtifact, VideoArtifact -from celeste.mime_types import VideoMimeType -from celeste.parameters import ParameterMapper -from celeste_video_generation.client import VideoGenerationClient -from celeste_video_generation.io import ( - VideoGenerationInput, - VideoGenerationUsage, -) -from celeste_video_generation.parameters import VideoGenerationParameters - -from .parameters import BYTEPLUS_PARAMETER_MAPPERS - -logger = logging.getLogger(__name__) - - -class BytePlusVideoGenerationClient(BytePlusVideosClient, VideoGenerationClient): - """BytePlus client for video generation.""" - - @classmethod - def parameter_mappers(cls) -> list[ParameterMapper]: - return BYTEPLUS_PARAMETER_MAPPERS - - def _validate_artifacts( - self, - inputs: VideoGenerationInput, - **parameters: Unpack[VideoGenerationParameters], - ) -> tuple[VideoGenerationInput, dict[str, Any]]: - """Validate and prepare artifacts for BytePlus API.""" - - def convert_to_data_url(img: ImageArtifact) -> ImageArtifact: - if img.url: - return img - elif img.data: - file_data = img.data - elif img.path: - with open(img.path, "rb") as f: - file_data = f.read() - else: - msg = "ImageArtifact must have url, data, or path" - raise ValueError(msg) - - base64_data = base64.b64encode(file_data).decode("utf-8") - mime_type = img.mime_type.value if img.mime_type else "image/jpeg" - - return ImageArtifact( - url=f"data:{mime_type};base64,{base64_data}", - mime_type=img.mime_type, - metadata=img.metadata, - ) - - reference_images = parameters.get("reference_images") - if reference_images: - converted_images = [convert_to_data_url(img) for img in reference_images] - parameters["reference_images"] = converted_images - - first_frame = parameters.get("first_frame") - if first_frame: - parameters["first_frame"] = convert_to_data_url(first_frame) - - last_frame = parameters.get("last_frame") - if last_frame: - parameters["last_frame"] = convert_to_data_url(last_frame) - - return inputs, dict(parameters) - - def _add_image_content_item( - self, - content: list[dict[str, Any]], - artifact: ImageArtifact | VideoArtifact, - role: str, - artifact_type: str, - ) -> None: - """Add image content item to content array.""" - if artifact.url: - content.append( - { - "type": "image_url", - "image_url": { - "url": artifact.url, - }, - "role": role, - } - ) - elif artifact.data: - logger.warning( - f"BytePlus requires {artifact_type} URL, not base64 data. Upload {artifact_type} first." - ) - elif artifact.path: - logger.warning( - f"BytePlus requires {artifact_type} URL, not file path. Upload {artifact_type} first." - ) - - def _init_request(self, inputs: VideoGenerationInput) -> dict[str, Any]: - """Initialize request from BytePlus ModelArk API format.""" - content: list[dict[str, Any]] = [ - { - "type": "text", - "text": inputs.prompt, - } - ] - - request: dict[str, Any] = { - "model": self.model.id, - "content": content, - } - - return request - - def _parse_usage(self, response_data: dict[str, Any]) -> VideoGenerationUsage: - """Parse usage from response.""" - usage = super()._parse_usage(response_data) - return VideoGenerationUsage(**usage) - - def _parse_content( - self, - response_data: dict[str, Any], - **parameters: Unpack[VideoGenerationParameters], - ) -> VideoArtifact: - """Parse content from response.""" - content = response_data.get("content") - if not isinstance(content, dict): - msg = f"No content field in BytePlus response. Available keys: {list(response_data.keys())}" - raise ValueError(msg) - - video_url = content.get("video_url") - if not video_url: - msg = f"No video_url in content field. Available content keys: {list(content.keys())}" - raise ValueError(msg) - - return VideoArtifact( - url=video_url, - mime_type=VideoMimeType.MP4, - ) - - def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: - """Build metadata dictionary from response data.""" - content_fields = {"content"} - filtered_data = { - k: v for k, v in response_data.items() if k not in content_fields - } - metadata = super()._build_metadata(filtered_data) - - task_id = response_data.get("id") - if task_id: - metadata["task_id"] = task_id - - status = response_data.get("status") - if status: - metadata["status"] = status - - content = response_data.get("content") - if isinstance(content, dict): - last_frame_url = content.get("last_frame_url") - if last_frame_url: - metadata["last_frame_url"] = last_frame_url - - return metadata - - -__all__ = ["BytePlusVideoGenerationClient"] diff --git a/packages/capabilities/video-generation/src/celeste_video_generation/providers/byteplus/parameters.py b/packages/capabilities/video-generation/src/celeste_video_generation/providers/byteplus/parameters.py deleted file mode 100644 index 3270606d..00000000 --- a/packages/capabilities/video-generation/src/celeste_video_generation/providers/byteplus/parameters.py +++ /dev/null @@ -1,51 +0,0 @@ -"""BytePlus parameter mappers for video generation.""" - -from celeste_byteplus.videos.parameters import ( - DurationMapper as _DurationMapper, -) -from celeste_byteplus.videos.parameters import ( - FirstFrameMapper as _FirstFrameMapper, -) -from celeste_byteplus.videos.parameters import ( - LastFrameMapper as _LastFrameMapper, -) -from celeste_byteplus.videos.parameters import ( - ReferenceImagesMapper as _ReferenceImagesMapper, -) -from celeste_byteplus.videos.parameters import ( - ResolutionMapper as _ResolutionMapper, -) - -from celeste.parameters import ParameterMapper -from celeste_video_generation.parameters import VideoGenerationParameter - - -class DurationMapper(_DurationMapper): - name = VideoGenerationParameter.DURATION - - -class ResolutionMapper(_ResolutionMapper): - name = VideoGenerationParameter.RESOLUTION - - -class ReferenceImagesMapper(_ReferenceImagesMapper): - name = VideoGenerationParameter.REFERENCE_IMAGES - - -class FirstFrameMapper(_FirstFrameMapper): - name = VideoGenerationParameter.FIRST_FRAME - - -class LastFrameMapper(_LastFrameMapper): - name = VideoGenerationParameter.LAST_FRAME - - -BYTEPLUS_PARAMETER_MAPPERS: list[ParameterMapper] = [ - DurationMapper(), - ResolutionMapper(), - ReferenceImagesMapper(), - FirstFrameMapper(), - LastFrameMapper(), -] - -__all__ = ["BYTEPLUS_PARAMETER_MAPPERS"] diff --git a/packages/capabilities/video-generation/src/celeste_video_generation/providers/google/__init__.py b/packages/capabilities/video-generation/src/celeste_video_generation/providers/google/__init__.py deleted file mode 100644 index 0616a9fd..00000000 --- a/packages/capabilities/video-generation/src/celeste_video_generation/providers/google/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Google provider for video generation.""" - -from celeste.core import Provider -from celeste_video_generation.providers.google.client import GoogleVideoGenerationClient - -__all__ = ["PROVIDERS", "GoogleVideoGenerationClient"] - -PROVIDERS: list[tuple[Provider, type[GoogleVideoGenerationClient]]] = [ - (Provider.GOOGLE, GoogleVideoGenerationClient), -] diff --git a/packages/capabilities/video-generation/src/celeste_video_generation/providers/google/client.py b/packages/capabilities/video-generation/src/celeste_video_generation/providers/google/client.py deleted file mode 100644 index 4eed0f19..00000000 --- a/packages/capabilities/video-generation/src/celeste_video_generation/providers/google/client.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Google client implementation for video generation.""" - -from typing import Any, Unpack - -from celeste_google.veo.client import GoogleVeoClient - -from celeste.artifacts import VideoArtifact -from celeste.mime_types import VideoMimeType -from celeste.parameters import ParameterMapper -from celeste_video_generation.client import VideoGenerationClient -from celeste_video_generation.io import ( - VideoGenerationInput, - VideoGenerationUsage, -) -from celeste_video_generation.parameters import VideoGenerationParameters - -from .parameters import GOOGLE_PARAMETER_MAPPERS - - -class GoogleVideoGenerationClient(GoogleVeoClient, VideoGenerationClient): - """Google client for video generation.""" - - @classmethod - def parameter_mappers(cls) -> list[ParameterMapper]: - return GOOGLE_PARAMETER_MAPPERS - - def _init_request(self, inputs: VideoGenerationInput) -> dict[str, Any]: - """Initialize request from Google API format.""" - instance: dict[str, Any] = {"prompt": inputs.prompt} - - request: dict[str, Any] = {"instances": [instance]} - request["parameters"] = {} - - return request - - def _parse_usage(self, response_data: dict[str, Any]) -> VideoGenerationUsage: - """Parse usage from response.""" - usage = super()._parse_usage(response_data) - return VideoGenerationUsage(**usage) - - def _parse_content( - self, - response_data: dict[str, Any], - **parameters: Unpack[VideoGenerationParameters], - ) -> VideoArtifact: - """Parse content from response.""" - video_data = super()._parse_content(response_data) - uri = video_data.get("uri") - if not uri: - msg = "No video URI in response" - raise ValueError(msg) - - video_artifact = VideoArtifact(url=uri) - - transformed = self._transform_output(video_artifact, **parameters) - if isinstance(transformed, VideoArtifact): - return transformed - return video_artifact - - async def download_content(self, artifact: VideoArtifact) -> VideoArtifact: - """Download video content from URI.""" - if not artifact.url: - msg = "VideoArtifact has no URL to download from" - raise ValueError(msg) - - video_bytes = await super().download_content(artifact.url) - - return VideoArtifact( - url=artifact.url, - data=video_bytes, - mime_type=VideoMimeType.MP4, - metadata=artifact.metadata, - ) - - -__all__ = ["GoogleVideoGenerationClient"] diff --git a/packages/capabilities/video-generation/src/celeste_video_generation/providers/google/models.py b/packages/capabilities/video-generation/src/celeste_video_generation/providers/google/models.py deleted file mode 100644 index 857ebb2d..00000000 --- a/packages/capabilities/video-generation/src/celeste_video_generation/providers/google/models.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Google models for video generation.""" - -from celeste import Model, Provider -from celeste.constraints import Choice, ImageConstraint, ImagesConstraint -from celeste.mime_types import ImageMimeType -from celeste_video_generation.parameters import VideoGenerationParameter - -# Supported MIME types for all Veo models -VEO_SUPPORTED_MIME_TYPES = [ - ImageMimeType.JPEG, - ImageMimeType.PNG, - ImageMimeType.WEBP, -] - -MODELS: list[Model] = [ - Model( - id="veo-3.0-generate-001", - provider=Provider.GOOGLE, - display_name="Veo 3", - parameter_constraints={ - VideoGenerationParameter.ASPECT_RATIO: Choice(options=["16:9", "9:16"]), - VideoGenerationParameter.RESOLUTION: Choice(options=["720p"]), - VideoGenerationParameter.DURATION: Choice(options=[4, 6, 8]), - VideoGenerationParameter.FIRST_FRAME: ImageConstraint( - supported_mime_types=VEO_SUPPORTED_MIME_TYPES, - ), - }, - ), - Model( - id="veo-3.0-fast-generate-001", - provider=Provider.GOOGLE, - display_name="Veo 3 Fast", - parameter_constraints={ - VideoGenerationParameter.ASPECT_RATIO: Choice(options=["16:9", "9:16"]), - VideoGenerationParameter.RESOLUTION: Choice(options=["720p"]), - VideoGenerationParameter.DURATION: Choice(options=[4, 6, 8]), - VideoGenerationParameter.FIRST_FRAME: ImageConstraint( - supported_mime_types=VEO_SUPPORTED_MIME_TYPES - ), - }, - ), - Model( - id="veo-3.1-generate-preview", - provider=Provider.GOOGLE, - display_name="Veo 3.1 (Preview)", - parameter_constraints={ - VideoGenerationParameter.ASPECT_RATIO: Choice(options=["16:9", "9:16"]), - VideoGenerationParameter.RESOLUTION: Choice(options=["720p", "1080p"]), - VideoGenerationParameter.DURATION: Choice(options=[4, 6, 8]), - VideoGenerationParameter.REFERENCE_IMAGES: ImagesConstraint( - supported_mime_types=VEO_SUPPORTED_MIME_TYPES, - max_count=3, - ), - VideoGenerationParameter.FIRST_FRAME: ImageConstraint( - supported_mime_types=VEO_SUPPORTED_MIME_TYPES, - ), - VideoGenerationParameter.LAST_FRAME: ImageConstraint( - supported_mime_types=VEO_SUPPORTED_MIME_TYPES, - ), - }, - ), - Model( - id="veo-3.1-fast-generate-preview", - provider=Provider.GOOGLE, - display_name="Veo 3.1 Fast (Preview)", - parameter_constraints={ - VideoGenerationParameter.ASPECT_RATIO: Choice(options=["16:9", "9:16"]), - VideoGenerationParameter.RESOLUTION: Choice(options=["720p", "1080p"]), - VideoGenerationParameter.DURATION: Choice(options=[4, 6, 8]), - VideoGenerationParameter.FIRST_FRAME: ImageConstraint( - supported_mime_types=VEO_SUPPORTED_MIME_TYPES, - ), - VideoGenerationParameter.LAST_FRAME: ImageConstraint( - supported_mime_types=VEO_SUPPORTED_MIME_TYPES, - ), - }, - ), -] diff --git a/packages/capabilities/video-generation/src/celeste_video_generation/providers/google/parameters.py b/packages/capabilities/video-generation/src/celeste_video_generation/providers/google/parameters.py deleted file mode 100644 index 217946f8..00000000 --- a/packages/capabilities/video-generation/src/celeste_video_generation/providers/google/parameters.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Google Veo parameter mappers for video generation.""" - -from celeste_google.veo.parameters import ( - AspectRatioMapper as _AspectRatioMapper, -) -from celeste_google.veo.parameters import ( - DurationSecondsMapper as _DurationSecondsMapper, -) -from celeste_google.veo.parameters import ( - FirstFrameMapper as _FirstFrameMapper, -) -from celeste_google.veo.parameters import ( - LastFrameMapper as _LastFrameMapper, -) -from celeste_google.veo.parameters import ( - ReferenceImagesMapper as _ReferenceImagesMapper, -) -from celeste_google.veo.parameters import ( - ResolutionMapper as _ResolutionMapper, -) - -from celeste.parameters import ParameterMapper -from celeste_video_generation.parameters import VideoGenerationParameter - - -class AspectRatioMapper(_AspectRatioMapper): - name = VideoGenerationParameter.ASPECT_RATIO - - -class ResolutionMapper(_ResolutionMapper): - name = VideoGenerationParameter.RESOLUTION - - -class DurationMapper(_DurationSecondsMapper): - name = VideoGenerationParameter.DURATION - - -class ReferenceImagesMapper(_ReferenceImagesMapper): - name = VideoGenerationParameter.REFERENCE_IMAGES - - -class FirstFrameMapper(_FirstFrameMapper): - name = VideoGenerationParameter.FIRST_FRAME - - -class LastFrameMapper(_LastFrameMapper): - name = VideoGenerationParameter.LAST_FRAME - - -GOOGLE_PARAMETER_MAPPERS: list[ParameterMapper] = [ - AspectRatioMapper(), - ResolutionMapper(), - DurationMapper(), - ReferenceImagesMapper(), - FirstFrameMapper(), - LastFrameMapper(), -] - -__all__ = ["GOOGLE_PARAMETER_MAPPERS"] diff --git a/packages/capabilities/video-generation/src/celeste_video_generation/providers/openai/__init__.py b/packages/capabilities/video-generation/src/celeste_video_generation/providers/openai/__init__.py deleted file mode 100644 index c951d3d4..00000000 --- a/packages/capabilities/video-generation/src/celeste_video_generation/providers/openai/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""OpenAI provider for video generation.""" - -from celeste.core import Provider -from celeste_video_generation.providers.openai.client import OpenAIVideoGenerationClient - -__all__ = ["PROVIDERS", "OpenAIVideoGenerationClient"] - -PROVIDERS: list[tuple[Provider, type[OpenAIVideoGenerationClient]]] = [ - (Provider.OPENAI, OpenAIVideoGenerationClient), -] diff --git a/packages/capabilities/video-generation/src/celeste_video_generation/providers/openai/client.py b/packages/capabilities/video-generation/src/celeste_video_generation/providers/openai/client.py deleted file mode 100644 index 01bedeb8..00000000 --- a/packages/capabilities/video-generation/src/celeste_video_generation/providers/openai/client.py +++ /dev/null @@ -1,140 +0,0 @@ -"""OpenAI client implementation for video generation.""" - -import base64 -import io -import json -from typing import Any, Unpack - -from celeste_openai.videos.client import OpenAIVideosClient -from PIL import Image - -from celeste.artifacts import ImageArtifact, VideoArtifact -from celeste.exceptions import ValidationError -from celeste.mime_types import VideoMimeType -from celeste.parameters import ParameterMapper -from celeste_video_generation.client import VideoGenerationClient -from celeste_video_generation.io import ( - VideoGenerationInput, - VideoGenerationUsage, -) -from celeste_video_generation.parameters import VideoGenerationParameters - -from .parameters import OPENAI_PARAMETER_MAPPERS - - -class OpenAIVideoGenerationClient(OpenAIVideosClient, VideoGenerationClient): - """OpenAI client for video generation.""" - - @classmethod - def parameter_mappers(cls) -> list[ParameterMapper]: - return OPENAI_PARAMETER_MAPPERS - - def _init_request(self, inputs: VideoGenerationInput) -> dict[str, Any]: - """Initialize request from OpenAI API format.""" - request = { - "prompt": inputs.prompt, - "model": self.model.id, - } - - return request - - def _build_request( - self, - inputs: VideoGenerationInput, - **parameters: Unpack[VideoGenerationParameters], - ) -> dict[str, Any]: - """Build request with parameter mapping and size derivation.""" - request = super()._build_request(inputs, **parameters) - - aspect_ratio = parameters.get("aspect_ratio") - resolution = parameters.get("resolution") - - if bool(aspect_ratio) != bool(resolution): - msg = ( - "Both aspect_ratio and resolution must be specified together. " - f"Got aspect_ratio={aspect_ratio!r}, resolution={resolution!r}" - ) - raise ValidationError(msg) - - if aspect_ratio and resolution: - ASPECT_RATIO_MAP = { - ("16:9", "720p"): "1280x720", - ("9:16", "720p"): "720x1280", - } - - size = ASPECT_RATIO_MAP.get((aspect_ratio, resolution)) - if size: - request["size"] = size - - return request - - def _parse_usage(self, response_data: dict[str, Any]) -> VideoGenerationUsage: - """Parse usage from response.""" - usage = super()._parse_usage(response_data) - return VideoGenerationUsage(**usage) - - def _parse_content( - self, - response_data: dict[str, Any], - **parameters: Unpack[VideoGenerationParameters], - ) -> VideoArtifact: - """Parse content from response.""" - video_data_b64 = response_data["video_data"] - video_data = base64.b64decode(video_data_b64) - return VideoArtifact( - data=video_data, - mime_type=VideoMimeType.MP4, - ) - - async def _prepare_multipart_request( - self, - request_body: dict[str, Any], - ) -> tuple[dict[str, tuple[str, bytes, str]], dict[str, str]]: - """Prepare multipart form data from request_body with input_reference.""" - size = request_body.get("size", "720x1280") - - input_reference = request_body.pop("input_reference", None) - if input_reference is None: - return {}, {} - - if not isinstance(input_reference, ImageArtifact): - msg = f"input_reference must be ImageArtifact, got {type(input_reference).__name__}" - raise ValueError(msg) - - if input_reference.data: - image_data = input_reference.data - elif input_reference.path: - with open(input_reference.path, "rb") as f: - image_data = f.read() - else: - msg = "ImageArtifact must have data or path for input_reference" - raise ValueError(msg) - - img = Image.open(io.BytesIO(image_data)) - actual_size = f"{img.width}x{img.height}" - if actual_size != size: - msg = ( - f"Image dimensions ({actual_size}) must match video size ({size}). " - f"Please resize your image to {size} before uploading." - ) - raise ValueError(msg) - - mime_type = ( - input_reference.mime_type.value - if input_reference.mime_type - else "image/jpeg" - ) - - files = { - "input_reference": ("image.jpg", image_data, mime_type), - } - - data = { - k: str(v) if isinstance(v, (str, int, float)) else json.dumps(v) - for k, v in request_body.items() - } - - return files, data - - -__all__ = ["OpenAIVideoGenerationClient"] diff --git a/packages/capabilities/video-generation/src/celeste_video_generation/providers/openai/models.py b/packages/capabilities/video-generation/src/celeste_video_generation/providers/openai/models.py deleted file mode 100644 index 25d8192f..00000000 --- a/packages/capabilities/video-generation/src/celeste_video_generation/providers/openai/models.py +++ /dev/null @@ -1,46 +0,0 @@ -"""OpenAI models for video generation.""" - -from celeste import Model, Provider -from celeste.constraints import Choice, ImageConstraint -from celeste.mime_types import ImageMimeType -from celeste_video_generation.parameters import VideoGenerationParameter - -MODELS: list[Model] = [ - Model( - id="sora-2", - provider=Provider.OPENAI, - display_name="Sora 2", - parameter_constraints={ - VideoGenerationParameter.DURATION: Choice(options=["4", "8", "12"]), - VideoGenerationParameter.ASPECT_RATIO: Choice(options=["16:9", "9:16"]), - VideoGenerationParameter.RESOLUTION: Choice(options=["720p"]), - }, - ), - Model( - id="sora-2-pro", - provider=Provider.OPENAI, - display_name="Sora 2 Pro", - parameter_constraints={ - VideoGenerationParameter.DURATION: Choice(options=["4", "8", "12"]), - VideoGenerationParameter.ASPECT_RATIO: Choice(options=["16:9", "9:16"]), - VideoGenerationParameter.RESOLUTION: Choice(options=["720p"]), - VideoGenerationParameter.FIRST_FRAME: ImageConstraint( - supported_mime_types=[ - ImageMimeType.JPEG, - ImageMimeType.PNG, - ImageMimeType.WEBP, - ], - ), - }, - ), - Model( - id="sora-2-2025-12-08", - provider=Provider.OPENAI, - display_name="Sora 2 (December 2025)", - parameter_constraints={ - VideoGenerationParameter.DURATION: Choice(options=["4", "8", "12"]), - VideoGenerationParameter.ASPECT_RATIO: Choice(options=["16:9", "9:16"]), - VideoGenerationParameter.RESOLUTION: Choice(options=["720p"]), - }, - ), -] diff --git a/packages/capabilities/video-generation/src/celeste_video_generation/providers/openai/parameters.py b/packages/capabilities/video-generation/src/celeste_video_generation/providers/openai/parameters.py deleted file mode 100644 index 19fd2c3c..00000000 --- a/packages/capabilities/video-generation/src/celeste_video_generation/providers/openai/parameters.py +++ /dev/null @@ -1,78 +0,0 @@ -"""OpenAI Videos parameter mappers for video generation.""" - -from typing import Any - -from celeste_openai.videos.parameters import ( - InputReferenceMapper as _InputReferenceMapper, -) -from celeste_openai.videos.parameters import ( - SecondsMapper as _SecondsMapper, -) - -from celeste.models import Model -from celeste.parameters import ParameterMapper -from celeste_video_generation.parameters import VideoGenerationParameter - - -class AspectRatioMapper(ParameterMapper): - """Validate aspect_ratio parameter. - - Validation only - size derivation happens in client. - """ - - name = VideoGenerationParameter.ASPECT_RATIO - - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Validate aspect_ratio parameter.""" - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - return request - - -class ResolutionMapper(ParameterMapper): - """Validate resolution parameter. - - Validation only - size derivation happens in client. - """ - - name = VideoGenerationParameter.RESOLUTION - - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Validate resolution parameter.""" - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - return request - - -class DurationMapper(_SecondsMapper): - """Map duration parameter to OpenAI API format.""" - - name = VideoGenerationParameter.DURATION - - -class FirstFrameMapper(_InputReferenceMapper): - """Map first_frame parameter to OpenAI API format.""" - - name = VideoGenerationParameter.FIRST_FRAME - - -OPENAI_PARAMETER_MAPPERS: list[ParameterMapper] = [ - AspectRatioMapper(), - ResolutionMapper(), - DurationMapper(), - FirstFrameMapper(), -] - -__all__ = ["OPENAI_PARAMETER_MAPPERS"] diff --git a/packages/capabilities/video-generation/src/celeste_video_generation/py.typed b/packages/capabilities/video-generation/src/celeste_video_generation/py.typed deleted file mode 100644 index 321d0ae1..00000000 --- a/packages/capabilities/video-generation/src/celeste_video_generation/py.typed +++ /dev/null @@ -1 +0,0 @@ -# Marker file for PEP 561 - this package supports type checking diff --git a/packages/capabilities/video-generation/tests/integration_tests/test_video_generation/__init__.py b/packages/capabilities/video-generation/tests/integration_tests/test_video_generation/__init__.py deleted file mode 100644 index 25f049b6..00000000 --- a/packages/capabilities/video-generation/tests/integration_tests/test_video_generation/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Video generation integration test module.""" diff --git a/packages/capabilities/video-generation/tests/integration_tests/test_video_generation/test_generate.py b/packages/capabilities/video-generation/tests/integration_tests/test_video_generation/test_generate.py deleted file mode 100644 index 3c9c0ff2..00000000 --- a/packages/capabilities/video-generation/tests/integration_tests/test_video_generation/test_generate.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Integration tests for video generation across all providers.""" - -import pytest - -from celeste import Capability, Provider, create_client - - -@pytest.mark.parametrize( - ("provider", "model", "parameters"), - [ - ( - Provider.OPENAI, - "sora-2", - {"duration": "4", "aspect_ratio": "16:9", "resolution": "720p"}, - ), - ( - Provider.GOOGLE, - "veo-3.0-fast-generate-001", - {"duration": 4, "resolution": "720p"}, - ), - ( - Provider.BYTEPLUS, - "seedance-1-0-lite-t2v-250428", - {"duration": 2, "resolution": "480p"}, - ), - ], -) -@pytest.mark.integration -@pytest.mark.asyncio -async def test_generate(provider: Provider, model: str, parameters: dict) -> None: - """Test video generation across all providers. Uses cheapest models.""" - # Import inside function to avoid circular import - from celeste_video_generation import VideoGenerationOutput, VideoGenerationUsage - - from celeste.artifacts import VideoArtifact - - # Arrange - client = create_client( - capability=Capability.VIDEO_GENERATION, - provider=provider, - model=model, - ) - prompt = "A cat playing with a ball" - - # Act - response = await client.generate( - prompt=prompt, - **parameters, - ) - - # Assert - assert isinstance(response, VideoGenerationOutput), ( - f"Expected VideoGenerationOutput, got {type(response)}" - ) - assert isinstance(response.content, VideoArtifact), ( - f"Expected VideoArtifact content, got {type(response.content)}" - ) - assert response.content.has_content, ( - f"VideoArtifact has no content (url/data/path): {response.content}" - ) - - # Validate usage metrics - assert isinstance(response.usage, VideoGenerationUsage), ( - f"Expected VideoGenerationUsage, got {type(response.usage)}" - ) diff --git a/packages/providers/anthropic/pyproject.toml b/packages/providers/anthropic/pyproject.toml deleted file mode 100644 index 2904a69f..00000000 --- a/packages/providers/anthropic/pyproject.toml +++ /dev/null @@ -1,18 +0,0 @@ -[project] -name = "celeste-anthropic" -version = "0.3.6" -description = "Anthropic provider package for Celeste AI" -authors = [{name = "Kamilbenkirane", email = "kamil@withceleste.ai"}] -license = {text = "Apache-2.0"} -requires-python = ">=3.12" -dependencies = ["celeste-ai", "httpx"] - -[tool.uv.sources] -celeste-ai = { workspace = true } - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/celeste_anthropic"] diff --git a/packages/providers/anthropic/src/celeste_anthropic/__init__.py b/packages/providers/anthropic/src/celeste_anthropic/__init__.py deleted file mode 100644 index a421162a..00000000 --- a/packages/providers/anthropic/src/celeste_anthropic/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Anthropic provider package for Celeste AI.""" - -__all__: list[str] = [] diff --git a/packages/providers/anthropic/src/celeste_anthropic/messages/streaming.py b/packages/providers/anthropic/src/celeste_anthropic/messages/streaming.py deleted file mode 100644 index cca7eab4..00000000 --- a/packages/providers/anthropic/src/celeste_anthropic/messages/streaming.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Anthropic Messages SSE parsing for streaming.""" - -from typing import Any - -from .client import AnthropicMessagesClient - - -class AnthropicMessagesStream: - """Mixin for Messages API SSE parsing. - - Provides shared implementation for all capabilities using Anthropic Messages API streaming: - - _parse_chunk() - Parse SSE event into raw chunk dict - - Capability streams extend via super() to wrap results in typed Chunks. - - Usage: - class AnthropicTextGenerationStream(AnthropicMessagesStream, TextGenerationStream): - def _parse_chunk(self, event): - raw = super()._parse_chunk(event) - if not raw: - return None - return TextGenerationChunk(...) - """ - - def _parse_chunk(self, event: dict[str, Any]) -> dict[str, Any] | None: - """Parse SSE event into raw chunk data.""" - event_type = event.get("type") - if not event_type: - return None - - if event_type == "content_block_delta": - delta = event.get("delta", {}) - if delta.get("type") == "text_delta": - text_delta = delta.get("text") - if text_delta is not None: - return { - "content": text_delta, - "finish_reason": None, - "usage": None, - "raw_event": event, - } - return None - - if event_type == "message_delta": - delta = event.get("delta", {}) - stop_reason = delta.get("stop_reason") - - usage = None - usage_data = event.get("usage") - if usage_data: - usage = AnthropicMessagesClient.map_usage_fields(usage_data) - - return { - "content": "", - "finish_reason": stop_reason, - "usage": usage, - "raw_event": event, - } - - if event_type == "message_stop": - usage = None - usage_data = event.get("usage") - if usage_data: - usage = AnthropicMessagesClient.map_usage_fields(usage_data) - - return { - "content": "", - "finish_reason": None, - "usage": usage, - "raw_event": event, - } - - return None - - -__all__ = ["AnthropicMessagesStream"] diff --git a/packages/providers/bfl/pyproject.toml b/packages/providers/bfl/pyproject.toml deleted file mode 100644 index 6fabd92c..00000000 --- a/packages/providers/bfl/pyproject.toml +++ /dev/null @@ -1,18 +0,0 @@ -[project] -name = "celeste-bfl" -version = "0.3.6" -description = "BFL (Black Forest Labs) provider package for Celeste AI" -authors = [{name = "Kamilbenkirane", email = "kamil@withceleste.ai"}] -license = {text = "Apache-2.0"} -requires-python = ">=3.12" -dependencies = ["celeste-ai", "httpx"] - -[tool.uv.sources] -celeste-ai = { workspace = true } - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/celeste_bfl"] diff --git a/packages/providers/bfl/src/celeste_bfl/__init__.py b/packages/providers/bfl/src/celeste_bfl/__init__.py deleted file mode 100644 index 0cf373b2..00000000 --- a/packages/providers/bfl/src/celeste_bfl/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""BFL (Black Forest Labs) provider package for Celeste AI.""" diff --git a/packages/providers/byteplus/pyproject.toml b/packages/providers/byteplus/pyproject.toml deleted file mode 100644 index 3393a3bc..00000000 --- a/packages/providers/byteplus/pyproject.toml +++ /dev/null @@ -1,18 +0,0 @@ -[project] -name = "celeste-byteplus" -version = "0.3.6" -description = "BytePlus provider package for Celeste AI" -authors = [{name = "Kamilbenkirane", email = "kamil@withceleste.ai"}] -license = {text = "Apache-2.0"} -requires-python = ">=3.12" - dependencies = ["celeste-ai", "httpx"] - -[tool.uv.sources] -celeste-ai = { workspace = true } - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/celeste_byteplus"] diff --git a/packages/providers/byteplus/src/celeste_byteplus/__init__.py b/packages/providers/byteplus/src/celeste_byteplus/__init__.py deleted file mode 100644 index ba7e53de..00000000 --- a/packages/providers/byteplus/src/celeste_byteplus/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""BytePlus provider package for Celeste AI.""" diff --git a/packages/providers/byteplus/src/celeste_byteplus/images/streaming.py b/packages/providers/byteplus/src/celeste_byteplus/images/streaming.py deleted file mode 100644 index 799b3b3b..00000000 --- a/packages/providers/byteplus/src/celeste_byteplus/images/streaming.py +++ /dev/null @@ -1,116 +0,0 @@ -"""BytePlus Images SSE parsing for streaming.""" - -from typing import Any - -from .client import BytePlusImagesClient - - -class BytePlusImagesStream: - """Mixin for BytePlus Images API SSE parsing. - - Provides shared implementation for capabilities using BytePlus Images API streaming: - - _parse_chunk() - Parse SSE event into raw chunk dict - - Handles all image streaming event types: - - image_generation.partial_succeeded - Partial image with url or b64_json - - image_generation.partial_failed - Error event - - image_generation.completed - Final event with usage only - - Capability streams extend via super() to wrap results in typed Chunks. - - Usage: - class BytePlusImageGenerationStream(BytePlusImagesStream, ImageGenerationStream): - def _parse_chunk(self, event): - raw = super()._parse_chunk(event) - if not raw: - return None - return ImageGenerationChunk(...) - """ - - def _parse_chunk(self, event: dict[str, Any]) -> dict[str, Any] | None: - """Parse SSE event into raw chunk data. - - Returns dict with: - - content_type: "url" or "b64_json" or None - - content: url string or b64_json string - - is_error: True for partial_failed events - - error: error dict for failed events - - usage: usage dict from completed event (None otherwise) - - metadata: model, created, image_index, size - - raw_event: original event dict - """ - event_type = event.get("type") - if not event_type: - return None - - # Handle successful partial image - if event_type == "image_generation.partial_succeeded": - url = event.get("url") - b64_json = event.get("b64_json") - - content_type = None - content = None - if url: - content_type = "url" - content = url - elif b64_json: - content_type = "b64_json" - content = b64_json - - if not content: - return None - - return { - "content_type": content_type, - "content": content, - "is_error": False, - "error": None, - "usage": None, - "metadata": { - "model": event.get("model"), - "created": event.get("created"), - "image_index": event.get("image_index"), - "size": event.get("size"), - }, - "raw_event": event, - } - - # Handle failed partial image - if event_type == "image_generation.partial_failed": - return { - "content_type": None, - "content": None, - "is_error": True, - "error": event.get("error"), - "usage": None, - "metadata": { - "model": event.get("model"), - "created": event.get("created"), - "image_index": event.get("image_index"), - }, - "raw_event": event, - } - - # Handle completed event (usage only, no image) - if event_type == "image_generation.completed": - usage_data = event.get("usage") - usage = None - if usage_data: - usage = BytePlusImagesClient.map_usage_fields(usage_data) - return { - "content_type": None, - "content": None, - "is_error": False, - "error": None, - "usage": usage, - "metadata": { - "model": event.get("model"), - "created": event.get("created"), - }, - "raw_event": event, - } - - return None - - -__all__ = ["BytePlusImagesStream"] diff --git a/packages/providers/cohere/pyproject.toml b/packages/providers/cohere/pyproject.toml deleted file mode 100644 index d141744a..00000000 --- a/packages/providers/cohere/pyproject.toml +++ /dev/null @@ -1,18 +0,0 @@ -[project] -name = "celeste-cohere" -version = "0.3.6" -description = "Cohere provider package for Celeste AI" -authors = [{name = "Kamilbenkirane", email = "kamil@withceleste.ai"}] -license = {text = "Apache-2.0"} -requires-python = ">=3.12" -dependencies = ["celeste-ai", "httpx"] - -[tool.uv.sources] -celeste-ai = { workspace = true } - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/celeste_cohere"] diff --git a/packages/providers/cohere/src/celeste_cohere/__init__.py b/packages/providers/cohere/src/celeste_cohere/__init__.py deleted file mode 100644 index e3f1b884..00000000 --- a/packages/providers/cohere/src/celeste_cohere/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Cohere provider package for Celeste AI.""" - -__all__: list[str] = [] diff --git a/packages/providers/cohere/src/celeste_cohere/chat/streaming.py b/packages/providers/cohere/src/celeste_cohere/chat/streaming.py deleted file mode 100644 index 4409733d..00000000 --- a/packages/providers/cohere/src/celeste_cohere/chat/streaming.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Cohere Chat SSE parsing for streaming.""" - -from typing import Any - -from .client import CohereChatClient - - -class CohereChatStream: - """Mixin for Chat API SSE parsing. - - Provides shared implementation for all capabilities using Cohere Chat API streaming: - - _parse_chunk() - Parse SSE event into raw chunk dict - - Capability streams extend via super() to wrap results in typed Chunks. - - Usage: - class CohereTextGenerationStream(CohereChatStream, TextGenerationStream): - def _parse_chunk(self, event): - raw = super()._parse_chunk(event) - if not raw: - return None - return TextGenerationChunk(...) - """ - - def _parse_chunk(self, event: dict[str, Any]) -> dict[str, Any] | None: - """Parse SSE event into raw chunk data.""" - event_type = event.get("type") - - if event_type == "content-delta": - delta = event.get("delta", {}) - message = delta.get("message", {}) - content = message.get("content", {}) - text_delta = content.get("text") - - if not text_delta: - return None - - return { - "content": text_delta, - "finish_reason": None, - "usage": None, - "raw_event": event, - } - - if event_type == "message-end": - delta = event.get("delta", {}) - finish_reason = delta.get("finish_reason") - - usage = None - usage_dict = delta.get("usage", {}) - if isinstance(usage_dict, dict): - mapped = CohereChatClient.map_usage_fields(usage_dict) - if ( - mapped.get("input_tokens") is not None - or mapped.get("output_tokens") is not None - ): - usage = mapped - - return { - "content": "", - "finish_reason": finish_reason, - "usage": usage, - "raw_event": event, - } - - if event_type == "stream-end": - finish_reason = event.get("finish_reason") - - usage = None - usage_data = event.get("usage", {}) - if isinstance(usage_data, dict): - mapped = CohereChatClient.map_usage_fields(usage_data) - if ( - mapped.get("input_tokens") is not None - or mapped.get("output_tokens") is not None - ): - usage = mapped - - return { - "content": "", - "finish_reason": finish_reason, - "usage": usage, - "raw_event": event, - } - - return None - - -__all__ = ["CohereChatStream"] diff --git a/packages/providers/elevenlabs/pyproject.toml b/packages/providers/elevenlabs/pyproject.toml deleted file mode 100644 index c5cdb0a8..00000000 --- a/packages/providers/elevenlabs/pyproject.toml +++ /dev/null @@ -1,18 +0,0 @@ -[project] -name = "celeste-elevenlabs" -version = "0.3.6" -description = "ElevenLabs provider package for Celeste AI" -authors = [{name = "Kamilbenkirane", email = "kamil@withceleste.ai"}] -license = {text = "Apache-2.0"} -requires-python = ">=3.12" -dependencies = ["celeste-ai", "httpx"] - -[tool.uv.sources] -celeste-ai = { workspace = true } - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/celeste_elevenlabs"] diff --git a/packages/providers/elevenlabs/src/celeste_elevenlabs/__init__.py b/packages/providers/elevenlabs/src/celeste_elevenlabs/__init__.py deleted file mode 100644 index 1017439c..00000000 --- a/packages/providers/elevenlabs/src/celeste_elevenlabs/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""ElevenLabs provider package for Celeste AI.""" - -__all__: list[str] = [] diff --git a/packages/providers/google/pyproject.toml b/packages/providers/google/pyproject.toml deleted file mode 100644 index 053aeaf9..00000000 --- a/packages/providers/google/pyproject.toml +++ /dev/null @@ -1,21 +0,0 @@ -[project] -name = "celeste-google" -version = "0.3.6" -description = "Google (Gemini API) provider package for Celeste AI" -authors = [{name = "Kamilbenkirane", email = "kamil@withceleste.ai"}] -license = {text = "Apache-2.0"} -requires-python = ">=3.12" -dependencies = ["celeste-ai", "httpx", "google-auth", "requests"] - -[tool.uv.sources] -celeste-ai = { workspace = true } - -[project.entry-points."celeste.providers"] -google = "celeste_google:register_provider" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/celeste_google"] diff --git a/packages/providers/google/src/celeste_google/__init__.py b/packages/providers/google/src/celeste_google/__init__.py deleted file mode 100644 index f1e9bd17..00000000 --- a/packages/providers/google/src/celeste_google/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Google provider package for Celeste AI.""" - - -def register_provider() -> None: - """Register Google provider auth types.""" - from celeste.auth import register_auth - from celeste_google.auth import GoogleADC - - register_auth("google_adc", GoogleADC) - - -__all__ = ["register_provider"] diff --git a/packages/providers/google/src/celeste_google/generate_content/streaming.py b/packages/providers/google/src/celeste_google/generate_content/streaming.py deleted file mode 100644 index 4d71d666..00000000 --- a/packages/providers/google/src/celeste_google/generate_content/streaming.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Google GenerateContent SSE parsing for streaming.""" - -from typing import Any - -from .client import GoogleGenerateContentClient - - -class GoogleGenerateContentStream: - """Mixin for GenerateContent SSE parsing. - - Provides shared implementation for all capabilities using GenerateContent streaming: - - _parse_chunk() - Parse SSE event into raw chunk dict - - Capability streams extend via super() to wrap results in typed Chunks. - - Usage: - class GoogleTextGenerationStream(GoogleGenerateContentStream, TextGenerationStream): - def _parse_chunk(self, event): - raw = super()._parse_chunk(event) - if not raw: - return None - return TextGenerationChunk(...) - """ - - def _parse_chunk(self, event: dict[str, Any]) -> dict[str, Any] | None: - """Parse SSE event into raw chunk data.""" - candidates = event.get("candidates", []) - if not candidates: - return None - - candidate = candidates[0] - content = candidate.get("content", {}) - parts = content.get("parts", []) - - text_delta = parts[0].get("text") if parts else None - finish_reason = candidate.get("finishReason") - - usage = None - usage_data = event.get("usageMetadata") - if usage_data: - usage = GoogleGenerateContentClient.map_usage_fields(usage_data) - - if not text_delta and not finish_reason: - return None - - return { - "content": text_delta or "", - "finish_reason": finish_reason, - "usage": usage, - "raw_event": event, - } - - -__all__ = ["GoogleGenerateContentStream"] diff --git a/packages/providers/gradium/pyproject.toml b/packages/providers/gradium/pyproject.toml deleted file mode 100644 index bd43ef45..00000000 --- a/packages/providers/gradium/pyproject.toml +++ /dev/null @@ -1,18 +0,0 @@ -[project] -name = "celeste-gradium" -version = "0.3.6" -description = "Gradium provider package for Celeste AI" -authors = [{name = "Kamilbenkirane", email = "kamil@withceleste.ai"}] -license = {text = "Apache-2.0"} -requires-python = ">=3.12" -dependencies = ["celeste-ai", "websockets"] - -[tool.uv.sources] -celeste-ai = { workspace = true } - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/celeste_gradium"] diff --git a/packages/providers/gradium/src/celeste_gradium/__init__.py b/packages/providers/gradium/src/celeste_gradium/__init__.py deleted file mode 100644 index a39dfaa4..00000000 --- a/packages/providers/gradium/src/celeste_gradium/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Gradium provider package for Celeste AI.""" - -__all__: list[str] = [] diff --git a/packages/providers/mistral/pyproject.toml b/packages/providers/mistral/pyproject.toml deleted file mode 100644 index 0e12b504..00000000 --- a/packages/providers/mistral/pyproject.toml +++ /dev/null @@ -1,18 +0,0 @@ -[project] -name = "celeste-mistral" -version = "0.3.6" -description = "Mistral provider package for Celeste AI" -authors = [{name = "Kamilbenkirane", email = "kamil@withceleste.ai"}] -license = {text = "Apache-2.0"} -requires-python = ">=3.12" -dependencies = ["celeste-ai", "httpx"] - -[tool.uv.sources] -celeste-ai = { workspace = true } - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/celeste_mistral"] diff --git a/packages/providers/mistral/src/celeste_mistral/__init__.py b/packages/providers/mistral/src/celeste_mistral/__init__.py deleted file mode 100644 index 6b0ceaea..00000000 --- a/packages/providers/mistral/src/celeste_mistral/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Mistral provider package for Celeste AI.""" diff --git a/packages/providers/mistral/src/celeste_mistral/chat/streaming.py b/packages/providers/mistral/src/celeste_mistral/chat/streaming.py deleted file mode 100644 index c128fa04..00000000 --- a/packages/providers/mistral/src/celeste_mistral/chat/streaming.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Mistral Chat SSE parsing for streaming.""" - -from typing import Any - -from .client import MistralChatClient - - -class MistralChatStream: - """Mixin for Chat API SSE parsing. - - Provides shared implementation for all capabilities using Mistral Chat API streaming: - - _parse_chunk() - Parse SSE event into raw chunk dict - - Capability streams extend via super() to wrap results in typed Chunks. - - Usage: - class MistralTextGenerationStream(MistralChatStream, TextGenerationStream): - def _parse_chunk(self, event): - raw = super()._parse_chunk(event) - if not raw: - return None - return TextGenerationChunk(...) - """ - - def _parse_chunk(self, event: dict[str, Any]) -> dict[str, Any] | None: - """Parse SSE event into raw chunk data.""" - choices = event.get("choices", []) - if not choices: - return None - - first_choice = choices[0] - if not isinstance(first_choice, dict): - return None - - delta = first_choice.get("delta", {}) - if not isinstance(delta, dict): - return None - - content_delta = delta.get("content") - - # Handle magistral thinking models that may return list content - if isinstance(content_delta, list): - text_parts = [] - for block in content_delta: - if isinstance(block, dict) and block.get("type") == "text": - text_parts.append(block.get("text", "")) - content_delta = "".join(text_parts) if text_parts else None - - finish_reason = first_choice.get("finish_reason") - - usage = None - usage_data = event.get("usage") - if isinstance(usage_data, dict): - usage = MistralChatClient.map_usage_fields(usage_data) - - if not content_delta and not finish_reason: - return None - - return { - "content": content_delta or "", - "finish_reason": finish_reason, - "usage": usage, - "raw_event": event, - } - - -__all__ = ["MistralChatStream"] diff --git a/packages/providers/openai/pyproject.toml b/packages/providers/openai/pyproject.toml deleted file mode 100644 index 2f4d888c..00000000 --- a/packages/providers/openai/pyproject.toml +++ /dev/null @@ -1,35 +0,0 @@ -[project] -name = "celeste-openai" -version = "0.3.6" -description = "OpenAI provider package for Celeste AI" -authors = [{name = "Kamilbenkirane", email = "kamil@withceleste.ai"}] -license = {text = "Apache-2.0"} -requires-python = ">=3.12" -dependencies = ["celeste-ai", "httpx"] -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", "openai", "gpt", "provider"] - -[project.urls] -Homepage = "https://withceleste.ai" -Documentation = "https://withceleste.ai/docs" -Repository = "https://github.com/withceleste/celeste-python" - -[tool.uv.sources] -celeste-ai = { workspace = true } - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/celeste_openai"] diff --git a/packages/providers/openai/src/celeste_openai/__init__.py b/packages/providers/openai/src/celeste_openai/__init__.py deleted file mode 100644 index ca08362a..00000000 --- a/packages/providers/openai/src/celeste_openai/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""OpenAI provider package for Celeste AI.""" diff --git a/packages/providers/openai/src/celeste_openai/images/streaming.py b/packages/providers/openai/src/celeste_openai/images/streaming.py deleted file mode 100644 index c13b46e2..00000000 --- a/packages/providers/openai/src/celeste_openai/images/streaming.py +++ /dev/null @@ -1,89 +0,0 @@ -"""OpenAI Images SSE parsing for streaming.""" - -from typing import Any - -from .client import OpenAIImagesClient - - -class OpenAIImagesStream: - """Mixin for Images API SSE parsing. - - Provides shared implementation for capabilities using OpenAI Images API streaming: - - _parse_chunk() - Parse SSE event into raw chunk dict - - Handles all image streaming event types: - - image_generation.partial_image / image_generation.completed - - image_edit.partial_image / image_edit.completed - - Capability streams extend via super() to wrap results in typed Chunks. - - Usage: - class OpenAIImageGenerationStream(OpenAIImagesStream, ImageGenerationStream): - def _parse_chunk(self, event): - raw = super()._parse_chunk(event) - if not raw: - return None - return ImageGenerationChunk(...) - """ - - def _parse_chunk(self, event: dict[str, Any]) -> dict[str, Any] | None: - """Parse SSE event into raw chunk data. - - Returns dict with: - - content: b64_json string (raw, not decoded) - - is_partial: True for partial_image events, False for completed - - usage: usage dict from completed events (None for partials) - - metadata: size, quality, output_format, background, created_at, partial_image_index - - raw_event: original event dict - """ - event_type = event.get("type") - if not event_type: - return None - - # Handle partial image events (generation or edit) - if event_type in ("image_generation.partial_image", "image_edit.partial_image"): - b64_json = event.get("b64_json") - if not b64_json: - return None - return { - "content": b64_json, - "is_partial": True, - "usage": None, - "metadata": { - "size": event.get("size"), - "quality": event.get("quality"), - "output_format": event.get("output_format"), - "background": event.get("background"), - "created_at": event.get("created_at"), - "partial_image_index": event.get("partial_image_index"), - }, - "raw_event": event, - } - - # Handle completed events (generation or edit) - if event_type in ("image_generation.completed", "image_edit.completed"): - b64_json = event.get("b64_json") - if not b64_json: - return None - usage_data = event.get("usage") - usage = None - if usage_data: - usage = OpenAIImagesClient.map_usage_fields(usage_data) - return { - "content": b64_json, - "is_partial": False, - "usage": usage, - "metadata": { - "size": event.get("size"), - "quality": event.get("quality"), - "output_format": event.get("output_format"), - "background": event.get("background"), - "created_at": event.get("created_at"), - }, - "raw_event": event, - } - - return None - - -__all__ = ["OpenAIImagesStream"] diff --git a/packages/providers/openai/src/celeste_openai/responses/streaming.py b/packages/providers/openai/src/celeste_openai/responses/streaming.py deleted file mode 100644 index bfbba7e3..00000000 --- a/packages/providers/openai/src/celeste_openai/responses/streaming.py +++ /dev/null @@ -1,68 +0,0 @@ -"""OpenAI Responses SSE parsing for streaming.""" - -from typing import Any - -from .client import OpenAIResponsesClient - - -class OpenAIResponsesStream: - """Mixin for Responses API SSE parsing. - - Provides shared implementation for all capabilities using OpenAI Responses API streaming: - - _parse_chunk() - Parse SSE event into raw chunk dict - - Capability streams extend via super() to wrap results in typed Chunks. - - Usage: - class OpenAITextGenerationStream(OpenAIResponsesStream, TextGenerationStream): - def _parse_chunk(self, event): - raw = super()._parse_chunk(event) - if not raw: - return None - return TextGenerationChunk(...) - """ - - def _parse_chunk(self, event: dict[str, Any]) -> dict[str, Any] | None: - """Parse SSE event into raw chunk data.""" - event_type = event.get("type") - if not event_type: - return None - - if event_type == "response.output_text.delta": - delta = event.get("delta") - if delta is None: - return None - return { - "content": delta, - "finish_reason": None, - "usage": None, - "raw_event": event, - } - - if event_type == "response.output_text.done": - return None - - if event_type == "response.completed": - response_data = event.get("response", {}) - usage_data = response_data.get("usage") - - usage = None - if usage_data: - usage = OpenAIResponsesClient.map_usage_fields(usage_data) - - finish_reason = None - status = response_data.get("status") - if status == "completed": - finish_reason = "completed" - - return { - "content": "", - "finish_reason": finish_reason, - "usage": usage, - "raw_event": event, - } - - return None - - -__all__ = ["OpenAIResponsesStream"] diff --git a/packages/providers/xai/pyproject.toml b/packages/providers/xai/pyproject.toml deleted file mode 100644 index fd1d83e1..00000000 --- a/packages/providers/xai/pyproject.toml +++ /dev/null @@ -1,18 +0,0 @@ -[project] -name = "celeste-xai" -version = "0.3.6" -description = "xAI provider package for Celeste AI" -authors = [{name = "Kamilbenkirane", email = "kamil@withceleste.ai"}] -license = {text = "Apache-2.0"} -requires-python = ">=3.12" -dependencies = ["celeste-ai", "httpx"] - -[tool.uv.sources] -celeste-ai = { workspace = true } - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/celeste_xai"] diff --git a/packages/providers/xai/src/celeste_xai/__init__.py b/packages/providers/xai/src/celeste_xai/__init__.py deleted file mode 100644 index 825b7690..00000000 --- a/packages/providers/xai/src/celeste_xai/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""XAI provider package for Celeste AI.""" diff --git a/packages/providers/xai/src/celeste_xai/responses/streaming.py b/packages/providers/xai/src/celeste_xai/responses/streaming.py deleted file mode 100644 index 998f6af9..00000000 --- a/packages/providers/xai/src/celeste_xai/responses/streaming.py +++ /dev/null @@ -1,71 +0,0 @@ -"""XAI Responses SSE parsing for streaming.""" - -from typing import Any - -from .client import XAIResponsesClient - - -class XAIResponsesStream: - """Mixin for Responses API SSE parsing. - - Provides shared implementation for all capabilities using XAI Responses API streaming: - - _parse_chunk() - Parse SSE event into raw chunk dict - - Capability streams extend via super() to wrap results in typed Chunks. - - Usage: - class XAITextGenerationStream(XAIResponsesStream, TextGenerationStream): - def _parse_chunk(self, event): - raw = super()._parse_chunk(event) - if not raw: - return None - return TextGenerationChunk(...) - """ - - def _parse_chunk(self, event: dict[str, Any]) -> dict[str, Any] | None: - """Parse SSE event into raw chunk data.""" - event_type = event.get("type") - if not event_type: - return None - - # Content delta events - if event_type == "response.output_text.delta": - delta = event.get("delta") - if delta is None: - return None - return { - "content": delta, - "finish_reason": None, - "usage": None, - "raw_event": event, - } - - # Ignore done event (no data to extract) - if event_type == "response.output_text.done": - return None - - # Completion event with usage - if event_type == "response.completed": - response_data = event.get("response", {}) - usage_data = response_data.get("usage") - - usage = None - if usage_data: - usage = XAIResponsesClient.map_usage_fields(usage_data) - - finish_reason = None - status = response_data.get("status") - if status == "completed": - finish_reason = "completed" - - return { - "content": "", - "finish_reason": finish_reason, - "usage": usage, - "raw_event": event, - } - - return None - - -__all__ = ["XAIResponsesStream"] diff --git a/pyproject.toml b/pyproject.toml index 1fa0b6f5..1ef1c336 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "celeste-ai" -version = "0.3.9" +version = "0.9.0" 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 = "MIT"} requires-python = ">=3.12" classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", @@ -17,14 +17,16 @@ classifiers = [ "Topic :: Scientific/Engineering :: Artificial Intelligence", "Typing :: Typed", ] -keywords = ["ai", "multimodal", "sdk", "openai", "anthropic", "claude", "gemini", "text-generation", "image-generation", "video-generation", "speech-generation", "embeddings"] +keywords = ["ai", "multimodal", "sdk", "openai", "anthropic", "claude", "gemini", "text-generation", "image-generation", "video-generation", "speech-generation", "text-embeddings"] dependencies = [ "pydantic>=2.0", "pydantic-settings>=2.0", "httpx>=0.27.0", "httpx-sse>=0.4.0", "python-dotenv>=1.0.0", - "websockets>=13.0", + "websockets>=15.0", + "asgiref>=3.11.0", + "filetype>=1.2.0", ] [project.urls] @@ -33,44 +35,18 @@ Documentation = "https://withceleste.ai/docs" Repository = "https://github.com/withceleste/celeste-python" Issues = "https://github.com/withceleste/celeste-python/issues" -[project.optional-dependencies] -text-generation = ["celeste-text-generation>=0.3.6"] -image-generation = ["celeste-image-generation>=0.3.6"] -video-generation = ["celeste-video-generation>=0.3.6"] -speech-generation = ["celeste-speech-generation>=0.3.6"] -all = [ - "celeste-text-generation>=0.3.6", - "celeste-image-generation>=0.3.6", - "celeste-video-generation>=0.3.6", - "celeste-speech-generation>=0.3.6", -] - [dependency-groups] dev = [ "pytest>=8.0", "pytest-cov>=7.0", - "pytest-randomly>=4.0", "pytest-asyncio>=1.2.0", "pytest-xdist>=3.0.0", "ruff>=0.8.0", "mypy>=1.13.0", - "types-requests>=2.31.0", "bandit[toml]>=1.7.5", "pre-commit>=3.5.0", ] -[tool.uv.workspace] -members = [ - "packages/providers/*", - "packages/capabilities/*", -] - -[tool.uv.sources] -celeste-text-generation = { workspace = true } -celeste-image-generation = { workspace = true } -celeste-video-generation = { workspace = true } -celeste-speech-generation = { workspace = true } - [build-system] requires = ["hatchling"] build-backend = "hatchling.build" @@ -83,13 +59,7 @@ minversion = "8.0" testpaths = ["tests"] addopts = "-ra --strict-markers --strict-config" asyncio_mode = "auto" -pythonpath = [ - "src", - "packages/capabilities/text-generation/src", - "packages/capabilities/image-generation/src", - "packages/capabilities/video-generation/src", - "packages/capabilities/speech-generation/src", -] +pythonpath = ["src"] markers = [ "slow: marks tests as slow (deselect with '-m \"not slow\"')", "smoke: quick checks for critical paths", @@ -99,6 +69,12 @@ markers = [ [tool.coverage.run] branch = true # dynamic_context handled by pytest-cov's --cov-context option +omit = [ + # Provider implementations (tested via integration tests, not unit tests) + "src/celeste/providers/*", + "src/celeste/modalities/*", + "src/celeste/namespaces/*" +] [tool.coverage.report] show_missing = true @@ -160,7 +136,7 @@ strict_equality = true module = "tests.*" disallow_untyped_defs = false # Relax for tests disallow_incomplete_defs = false -disable_error_code = ["override", "return-value", "arg-type", "call-arg", "assignment", "no-any-return"] +disable_error_code = ["override", "return-value", "arg-type", "call-arg", "assignment", "no-any-return", "attr-defined", "unused-ignore"] [[tool.mypy.overrides]] module = "httpx" @@ -170,18 +146,38 @@ ignore_missing_imports = true module = "httpx_sse" ignore_missing_imports = true +[[tool.mypy.overrides]] +module = "websockets.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "filetype" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "google.*" +ignore_missing_imports = true + [[tool.mypy.overrides]] module = [ - "celeste_text_generation.client", - "celeste_text_generation.providers.*", - "celeste_image_generation.client", - "celeste_image_generation.providers.*", - "celeste_video_generation.client", - "celeste_video_generation.providers.*", - "celeste_speech_generation.client", - "celeste_speech_generation.providers.*", + "celeste.modalities.text.client", + "celeste.modalities.text.streaming", + "celeste.modalities.text.providers.*", + "celeste.modalities.images.client", + "celeste.modalities.images.streaming", + "celeste.modalities.images.providers.*", + "celeste.modalities.videos.client", + "celeste.modalities.videos.providers.*", + "celeste.modalities.audio.client", + "celeste.modalities.audio.streaming", + "celeste.modalities.audio.providers.*", + "celeste.modalities.embeddings.client", + "celeste.modalities.embeddings.providers.*", + "celeste.providers.*.client", + "celeste.providers.*.*", + "celeste.namespaces.domains", ] -disable_error_code = ["override", "return-value", "arg-type", "call-arg", "assignment", "no-any-return"] +disable_error_code = ["override", "return-value", "arg-type", "call-arg", "assignment", "no-any-return", "attr-defined"] [tool.bandit] exclude_dirs = [".venv", "__pycache__"] diff --git a/src/celeste/__init__.py b/src/celeste/__init__.py index 9f53fb05..d345c721 100644 --- a/src/celeste/__init__.py +++ b/src/celeste/__init__.py @@ -1,11 +1,21 @@ +"""Celeste - Open source, type-safe primitives for multi-modal AI.""" + import logging import warnings from pydantic import SecretStr +from celeste import providers as _providers # noqa: F401 from celeste.auth import APIKey, Authentication -from celeste.client import Client, get_client_class, register_client -from celeste.core import Capability, Parameter, Provider, UsageField +from celeste.client import ModalityClient +from celeste.core import ( + Capability, + Modality, + Operation, + Parameter, + Provider, + UsageField, +) from celeste.credentials import credentials from celeste.exceptions import ( ClientNotFoundError, @@ -22,49 +32,74 @@ ValidationError, ) from celeste.http import HTTPClient, close_all_http_clients -from celeste.io import Input, Output, Usage, get_input_class, register_input -from celeste.models import Model, get_model, list_models, register_models +from celeste.io import Input, Output, Usage +from celeste.modalities.audio.models import MODELS as _audio_models +from celeste.modalities.audio.providers import PROVIDERS as _audio_providers +from celeste.modalities.embeddings.models import MODELS as _embeddings_models +from celeste.modalities.embeddings.providers import PROVIDERS as _embeddings_providers +from celeste.modalities.images.models import MODELS as _images_models +from celeste.modalities.images.providers import PROVIDERS as _images_providers +from celeste.modalities.text.models import MODELS as _text_models +from celeste.modalities.text.providers import PROVIDERS as _text_providers +from celeste.modalities.videos.models import MODELS as _videos_models +from celeste.modalities.videos.providers import PROVIDERS as _videos_providers +from celeste.models import Model, _models, get_model, list_models, register_models from celeste.parameters import Parameters -from celeste.registry import _load_from_entry_points from celeste.structured_outputs import ( RefResolvingJsonSchemaGenerator, StrictJsonSchemaGenerator, StrictRefResolvingJsonSchemaGenerator, ) from celeste.types import JsonValue -from celeste.utils import image_to_data_uri from celeste.websocket import WebSocketClient, WebSocketConnection, close_all_ws_clients logger = logging.getLogger(__name__) +_CLIENT_MAP: dict[tuple[Modality, Provider], type[ModalityClient]] = { + **{(Modality.TEXT, p): c for p, c in _text_providers.items()}, + **{(Modality.IMAGES, p): c for p, c in _images_providers.items()}, + **{(Modality.VIDEOS, p): c for p, c in _videos_providers.items()}, + **{(Modality.AUDIO, p): c for p, c in _audio_providers.items()}, + **{(Modality.EMBEDDINGS, p): c for p, c in _embeddings_providers.items()}, +} -def _infer_capability(model: Model) -> Capability: - """Infer capability from model. Raises if ambiguous.""" - if len(model.capabilities) == 1: - return next(iter(model.capabilities)) - if len(model.capabilities) > 1: - caps = ", ".join(c.value for c in model.capabilities) - msg = f"Model '{model.id}' supports multiple capabilities: {caps}. Specify 'capability' explicitly." - raise ValueError(msg) - msg = f"Model '{model.id}' has no registered capabilities" - raise ValueError(msg) +for _model in [ + *_text_models, + *_images_models, + *_videos_models, + *_audio_models, + *_embeddings_models, +]: + _models[(_model.id, _model.provider)] = _model + +_CAPABILITY_TO_MODALITY_OPERATION: dict[Capability, tuple[Modality, Operation]] = { + Capability.TEXT_GENERATION: (Modality.TEXT, Operation.GENERATE), + Capability.TEXT_EMBEDDINGS: (Modality.EMBEDDINGS, Operation.EMBED), + Capability.IMAGE_GENERATION: (Modality.IMAGES, Operation.GENERATE), + Capability.VIDEO_GENERATION: (Modality.VIDEOS, Operation.GENERATE), + Capability.SPEECH_GENERATION: (Modality.AUDIO, Operation.SPEAK), +} def _resolve_model( - capability: Capability | None, - provider: Provider | None, - model: Model | str | None, + modality: Modality | None = None, + operation: Operation | None = None, + provider: Provider | None = None, + model: Model | str | None = None, ) -> Model: """Resolve model parameter to Model object (auto-select if None, lookup if string).""" if model is None: - if capability is None: - msg = "Either 'capability' or 'model' must be provided" + if modality is None: + msg = "Either 'modality' or 'model' must be provided" raise ValueError(msg) - # Auto-select first available model - models = list_models(provider=provider, capability=capability) + models = list_models( + provider=provider, + modality=modality, + operation=operation, + ) if not models: raise ModelNotFoundError( - capability=capability, + modality=modality, provider=provider if provider else None, ) return models[0] @@ -74,10 +109,8 @@ def _resolve_model( if not found: if provider is None: raise ModelNotFoundError(model_id=model, provider=provider) - if capability is None: - msg = ( - f"Model '{model}' not registered. Specify 'capability' explicitly." - ) + if modality is None: + msg = f"Model '{model}' not registered. Specify 'modality' explicitly." raise ValueError(msg) warnings.warn( f"Model '{model}' not registered in Celeste for provider {provider.value}. " @@ -85,11 +118,14 @@ def _resolve_model( UserWarning, stacklevel=3, ) + operations: dict[Modality, set[Operation]] = {} + if modality is not None: + operations[modality] = {operation} if operation else set() return Model( id=model, provider=provider, display_name=model, - capabilities={capability}, + operations=operations, streaming=True, ) return found @@ -97,18 +133,46 @@ def _resolve_model( return model +def _infer_operation(model: Model, modality: Modality) -> Operation: + """Infer operation from model for a given modality. Raises if ambiguous.""" + if modality not in model.operations: + msg = f"Model '{model.id}' does not support modality '{modality.value}'" + raise ValueError(msg) + + operations = model.operations[modality] + if len(operations) == 1: + return next(iter(operations)) + if len(operations) > 1: + ops = ", ".join(o.value for o in operations) + msg = ( + f"Model '{model.id}' supports multiple operations for {modality.value}: {ops}. " + "Specify 'operation' explicitly." + ) + raise ValueError(msg) + msg = f"Model '{model.id}' has no registered operations for modality '{modality.value}'" + raise ValueError(msg) + + def create_client( capability: Capability | None = None, + modality: Modality | str | None = None, + operation: Operation | str | None = None, provider: Provider | None = None, model: Model | str | None = None, api_key: str | SecretStr | None = None, auth: Authentication | None = None, -) -> Client: - """Create an async client for the specified AI capability. +) -> ModalityClient: + """Create an async client for the specified AI capability or modality. Args: - capability: The AI capability to use. If not provided and model is specified, - capability is inferred from the model (if unambiguous). + capability: The AI capability to use (deprecated, use modality instead). + If not provided and model is specified, capability is inferred + from the model (if unambiguous). + modality: The modality to use (e.g., Modality.IMAGES, "images"). + Preferred over capability for new code. + operation: The operation to use (e.g., Operation.GENERATE, "generate"). + If not provided and model supports exactly one operation for the + modality, it is inferred automatically. provider: Optional provider. If not specified and model ID matches multiple providers, the first match is used with a warning. model: Model object, string model ID, or None for auto-selection. @@ -123,41 +187,61 @@ def create_client( ClientNotFoundError: If no client registered for capability/provider. MissingCredentialsError: If required credentials are not configured. UnsupportedCapabilityError: If the resolved model doesn't support the requested capability. - ValueError: If capability cannot be inferred from model. + ValueError: If capability/operation cannot be inferred from model. """ - # Load packages lazily when create_client is called - _load_from_entry_points() - # Resolve model - resolved_model = _resolve_model(capability, provider, model) - - # Infer capability if not provided - resolved_capability = ( - capability if capability else _infer_capability(resolved_model) + # Translation layer: convert deprecated capability to modality/operation + if capability is not None and modality is None: + warnings.warn( + "capability parameter is deprecated, use modality/operation instead", + DeprecationWarning, + stacklevel=2, + ) + if capability not in _CAPABILITY_TO_MODALITY_OPERATION: + msg = f"Unknown capability: {capability}" + raise ValueError(msg) + modality, operation = _CAPABILITY_TO_MODALITY_OPERATION[capability] + + if modality is None: + msg = "Either 'modality' or 'model' must be provided" + raise ValueError(msg) + + resolved_modality = Modality(modality) if isinstance(modality, str) else modality + resolved_operation = ( + Operation(operation) if isinstance(operation, str) else operation + ) + + resolved_model = _resolve_model( + modality=resolved_modality, + operation=resolved_operation, + provider=provider, + model=model, ) - # Get client class and authentication - client_class = get_client_class(resolved_capability, resolved_model.provider) + key = (resolved_modality, resolved_model.provider) + if key not in _CLIENT_MAP: + raise ClientNotFoundError( + modality=resolved_modality, provider=resolved_model.provider + ) + modality_client_class = _CLIENT_MAP[key] + resolved_auth = credentials.get_auth( resolved_model.provider, override_auth=auth, override_key=api_key, ) - # Create and return client - return client_class( + return modality_client_class( + modality=resolved_modality, model=resolved_model, provider=resolved_model.provider, - capability=resolved_capability, auth=resolved_auth, ) -# Exports __all__ = [ "APIKey", "Authentication", "Capability", - "Client", "ClientNotFoundError", "ConstraintViolationError", "Error", @@ -165,8 +249,11 @@ def create_client( "Input", "JsonValue", "MissingCredentialsError", + "Modality", + "ModalityClient", "Model", "ModelNotFoundError", + "Operation", "Output", "Parameter", "Parameters", @@ -185,15 +272,17 @@ def create_client( "ValidationError", "WebSocketClient", "WebSocketConnection", + "audio", "close_all_http_clients", "close_all_ws_clients", "create_client", - "get_client_class", - "get_input_class", "get_model", - "image_to_data_uri", + "images", "list_models", - "register_client", - "register_input", "register_models", + "text", + "videos", ] + +# Domain namespace API (imported last to avoid circular imports) +from celeste.namespaces import audio, images, text, videos # noqa: E402 diff --git a/src/celeste/artifacts.py b/src/celeste/artifacts.py index 113fa1b4..360c1238 100644 --- a/src/celeste/artifacts.py +++ b/src/celeste/artifacts.py @@ -1,10 +1,16 @@ """Unified artifact types for Celeste.""" +import base64 from typing import Any -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_serializer -from celeste.mime_types import AudioMimeType, ImageMimeType, MimeType, VideoMimeType +from celeste.mime_types import ( + AudioMimeType, + ImageMimeType, + MimeType, + VideoMimeType, +) class Artifact(BaseModel): @@ -24,6 +30,13 @@ class Artifact(BaseModel): mime_type: MimeType | None = None metadata: dict[str, Any] = Field(default_factory=dict) + @field_serializer("data", when_used="json") + def serialize_data(self, value: bytes | None) -> str | None: + """Serialize bytes as base64 string for JSON compatibility.""" + if value is None: + return None + return base64.b64encode(value).decode("ascii") + @property def has_content(self) -> bool: """Check if artifact has any content.""" @@ -33,6 +46,24 @@ def has_content(self) -> bool: or (self.path and self.path.strip()) ) + def get_bytes(self) -> bytes: + """Get raw bytes, reading from path if needed. + + Raises: + ValueError: If artifact has no data or path. + """ + if self.data: + return self.data + if self.path: + with open(self.path, "rb") as f: + return f.read() + msg = "Artifact must have data or path to get bytes" + raise ValueError(msg) + + def get_base64(self) -> str: + """Get base64-encoded string of the content.""" + return base64.b64encode(self.get_bytes()).decode("utf-8") + class ImageArtifact(Artifact): """Image artifact from generation/edit operations.""" diff --git a/src/celeste/auth.py b/src/celeste/auth.py index 14acadc1..824e58cb 100644 --- a/src/celeste/auth.py +++ b/src/celeste/auth.py @@ -17,21 +17,22 @@ def get_headers(self) -> dict[str, str]: ... -class APIKey(Authentication): - """API key authentication. - - Supports configurable header name and prefix for different provider formats: - - OpenAI: Authorization: Bearer - - Anthropic: x-api-key: - - Google: x-goog-api-key: - - ElevenLabs: xi-api-key: +class AuthHeader(Authentication): + """Authentication via HTTP header with configurable header name and prefix. + + This is the primitive for header-based authentication. Different providers + use different header names and prefixes: + - OpenAI: Authorization: Bearer + - Anthropic: x-api-key: + - Google: x-goog-api-key: + - ElevenLabs: xi-api-key: """ - key: SecretStr + secret: SecretStr header: str = "Authorization" prefix: str = "Bearer " - @field_validator("key", mode="before") + @field_validator("secret", mode="before") @classmethod def convert_to_secret(cls, v: str | SecretStr) -> SecretStr: """Accept plain strings, auto-convert to SecretStr.""" @@ -40,8 +41,12 @@ def convert_to_secret(cls, v: str | SecretStr) -> SecretStr: return v def get_headers(self) -> dict[str, str]: - """Return API key authentication header.""" - return {self.header: f"{self.prefix}{self.key.get_secret_value()}"} + """Return authentication header.""" + return {self.header: f"{self.prefix}{self.secret.get_secret_value()}"} + + +# Backwards compatibility alias +APIKey = AuthHeader def register_auth(auth_type: str, auth_class: type[Authentication]) -> None: @@ -66,10 +71,6 @@ def get_auth_class(auth_type: str) -> type[Authentication]: Raises: ValueError: If auth type is not registered. """ - from celeste.registry import _load_providers_from_entry_points - - _load_providers_from_entry_points() - if auth_type not in _auth_classes: msg = f"Unknown auth type: {auth_type}. Available: {list(_auth_classes.keys())}" raise ValueError(msg) @@ -77,4 +78,4 @@ def get_auth_class(auth_type: str) -> type[Authentication]: return _auth_classes[auth_type] -__all__ = ["APIKey", "Authentication", "get_auth_class", "register_auth"] +__all__ = ["APIKey", "AuthHeader", "Authentication", "get_auth_class", "register_auth"] diff --git a/src/celeste/client.py b/src/celeste/client.py index 21fe8046..165d1aee 100644 --- a/src/celeste/client.py +++ b/src/celeste/client.py @@ -1,4 +1,4 @@ -"""Base client and client registry for AI capabilities.""" +"""Base client for modality-specific AI operations.""" from abc import ABC, abstractmethod from collections.abc import AsyncIterator @@ -9,39 +9,35 @@ from pydantic import BaseModel, ConfigDict, Field from celeste.auth import Authentication -from celeste.core import Capability, Provider -from celeste.exceptions import ( - ClientNotFoundError, - StreamingNotSupportedError, - UnsupportedCapabilityError, -) +from celeste.core import Modality, Provider +from celeste.exceptions import StreamingNotSupportedError from celeste.http import HTTPClient, get_http_client from celeste.io import Chunk, FinishReason, Input, Output, Usage from celeste.models import Model from celeste.parameters import ParameterMapper, Parameters from celeste.streaming import Stream -from celeste.types import StructuredOutput +from celeste.types import TextContent class APIMixin(ABC): """Abstract base for provider API mixins. - Provider mixins inherit from this to gain type hints for Client attributes. - The actual attributes are provided by Client through multiple inheritance. + Provider mixins inherit from this to gain type hints for ModalityClient attributes. + The actual attributes are provided by ModalityClient through multiple inheritance. Layering: - HTTPClient: Low-level HTTP transport (requests, connection pooling) - APIMixin: High-level provider API logic (endpoints, request/response formats) - - Client: Capability-specific client (text generation, image generation, etc.) + - ModalityClient: Modality-specific client (text, images, audio, etc.) Example: - class OpenAIResponsesClient(APIMixin): + class OpenAIResponsesMixin(APIMixin): async def _make_request(self, request_body, **parameters): request_body["model"] = self.model.id # Type-safe! headers = {**self.auth.get_headers(), ...} return await self.http_client.post(...) - class OpenAITextGenerationClient(OpenAIResponsesClient, TextGenerationClient): + class OpenAITextClient(OpenAIResponsesMixin, TextClient): pass """ @@ -55,71 +51,124 @@ def http_client(self) -> HTTPClient: """HTTP client with connection pooling for this provider.""" ... - def _build_request(self, inputs: Any, **parameters: Any) -> dict[str, Any]: + @staticmethod + def _deep_merge( + target: dict[str, Any], + source: dict[str, Any], + ) -> dict[str, Any]: + """Deep merge source dictionary into target dictionary. + + Args: + target: The dictionary to merge into. + source: The dictionary to merge from. + + Returns: + The merged dictionary (modified target). + """ + for key, value in source.items(): + if ( + key in target + and isinstance(target[key], dict) + and isinstance(value, dict) + ): + APIMixin._deep_merge(target[key], value) + else: + target[key] = value + return target + + def _build_request( + self, + inputs: Any, + extra_body: dict[str, Any] | None = None, + streaming: bool = False, + **parameters: Any, + ) -> dict[str, Any]: """Build request dict from inputs and parameters. - Mixins override this and call super() to chain with Client._build_request(). + Mixins override this and call super() to chain with ModalityClient._build_request(). """ - return super()._build_request(inputs, **parameters) # type: ignore[misc,no-any-return] + return super()._build_request( # type: ignore[misc,no-any-return] + inputs, extra_body=extra_body, streaming=streaming, **parameters + ) def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: """Build metadata dict from response data. - Mixins override this and call super() to chain with Client._build_metadata(). + Mixins override this and call super() to chain with ModalityClient._build_metadata(). """ return super()._build_metadata(response_data) # type: ignore[misc,no-any-return] def _handle_error_response(self, response: httpx.Response) -> None: """Handle error responses from provider APIs. - Stub that calls through to Client._handle_error_response via MRO. + Stub that calls through to ModalityClient._handle_error_response via MRO. """ super()._handle_error_response(response) # type: ignore[misc] -class Client[In: Input, Out: Output, Params: Parameters](APIMixin, BaseModel): - """Base class for all capability-specific clients.""" +class ModalityClient[In: Input, Out: Output, Params: Parameters, Content]( + APIMixin, BaseModel +): + """Base class for unified modality clients. + + Operation methods in subclasses delegate to _predict(). + + Example: + class ImagesClient(ModalityClient[ImagesInput, ImagesOutput, ImagesParameters, ImageContent]): + modality = Modality.IMAGES + + async def generate(self, prompt: str, **parameters) -> ImageGenerationOutput: + inputs = ImageGenerationInput(prompt=prompt) + return await self._predict(inputs, **parameters) + """ - model_config = ConfigDict(from_attributes=True) + model_config = ConfigDict(arbitrary_types_allowed=True) + modality: Modality model: Model provider: Provider - capability: Capability auth: Authentication = Field(exclude=True) - def model_post_init(self, __context: object) -> None: - """Validate capability compatibility.""" - if self.capability not in self.model.capabilities: - raise UnsupportedCapabilityError( - model_id=self.model.id, - capability=self.capability, - ) - @property def http_client(self) -> HTTPClient: - """Shared HTTP client with connection pooling for this provider.""" - return get_http_client(self.provider, self.capability) + """Shared HTTP client with connection pooling.""" + return get_http_client(self.provider, self.modality) - async def generate( + # Namespace properties - implemented by modality clients + @property + def sync(self) -> Any: + """Sync namespace for blocking operations.""" + ... + + @property + def stream(self) -> Any: + """Stream namespace for streaming operations.""" + ... + + async def _predict( self, - *args: Any, + inputs: In, + *, + endpoint: str | None = None, + extra_body: dict[str, Any] | None = None, **parameters: Unpack[Params], # type: ignore[misc] ) -> Out: - """Generate content - signature varies by capability. + """Generic prediction - called by operation methods. Args: - *args: Capability-specific positional arguments (prompt, image, video, etc.). - **parameters: Capability-specific keyword arguments (temperature, max_tokens, etc.). + inputs: Operation-specific input object. + endpoint: Optional endpoint path (e.g., "/generations"). + extra_body: Additional parameters to merge into the request body. + **parameters: Operation-specific keyword arguments. Returns: - Output of the parameterized type (e.g., TextGenerationOutput). + Output of the parameterized type. """ - inputs = self._create_inputs(*args, **parameters) inputs, parameters = self._validate_artifacts(inputs, **parameters) - request_body = self._build_request(inputs, **parameters) - response = await self._make_request(request_body, **parameters) - self._handle_error_response(response) - response_data = response.json() + request_body = self._build_request(inputs, extra_body=extra_body, **parameters) + response_data = await self._make_request( + request_body, endpoint=endpoint, **parameters + ) return self._output_class()( content=self._parse_content(response_data, **parameters), usage=self._parse_usage(response_data), @@ -127,16 +176,23 @@ async def generate( metadata=self._build_metadata(response_data), ) - def stream( + def _stream( self, - *args: Any, + inputs: In, + stream_class: type[Stream[Out, Params, Chunk]], + extra_body: dict[str, Any] | None = None, **parameters: Unpack[Params], # type: ignore[misc] ) -> Stream[Out, Params, Chunk]: - """Stream content - signature varies by capability. + """Generic streaming - called by operation methods. + + Transport-agnostic: provider implements _make_stream_request() with + whatever transport is appropriate (HTTP SSE, WebSocket, etc.). Args: - *args: Capability-specific positional arguments (same as generate). - **parameters: Capability-specific keyword arguments (same as generate). + inputs: Operation-specific input object. + stream_class: The Stream class to instantiate. + extra_body: Additional parameters to merge into the request body. + **parameters: Operation-specific keyword arguments. Returns: Stream yielding chunks and providing final Output. @@ -147,13 +203,15 @@ def stream( if not self.model.streaming: raise StreamingNotSupportedError(model_id=self.model.id) - inputs = self._create_inputs(*args, **parameters) inputs, parameters = self._validate_artifacts(inputs, **parameters) - request_body = self._build_request(inputs, **parameters) + request_body = self._build_request( + inputs, extra_body=extra_body, streaming=True, **parameters + ) sse_iterator = self._make_stream_request(request_body, **parameters) - return self._stream_class()( + return stream_class( sse_iterator, transform_output=self._transform_output, + client=self, **parameters, ) @@ -163,11 +221,6 @@ 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.""" - ... - @abstractmethod def _parse_usage(self, response_data: dict[str, Any]) -> Usage: """Parse usage information from provider response.""" @@ -178,48 +231,33 @@ def _parse_content( self, response_data: dict[str, Any], **parameters: Unpack[Params], # type: ignore[misc] - ) -> StructuredOutput: + ) -> Content: """Parse content from provider response.""" ... def _parse_finish_reason( self, response_data: dict[str, Any] ) -> FinishReason | None: - """Parse finish reason from provider response. - - Default implementation returns None. Override in capability-specific - clients that support finish reasons (e.g., text-generation, image-generation). - """ + """Parse finish reason from provider response.""" return None - @abstractmethod - def _create_inputs( - self, - *args: Any, - **parameters: Unpack[Params], # type: ignore[misc] - ) -> In: - """Map positional arguments to Input type.""" - ... - @classmethod @abstractmethod def _output_class(cls) -> type[Out]: - """Return the Output class for this client.""" + """Return the Output class for this modality.""" ... @abstractmethod async def _make_request( self, request_body: dict[str, Any], + *, + endpoint: str | None = None, **parameters: Unpack[Params], # type: ignore[misc] - ) -> httpx.Response: - """Make HTTP request(s) and return response object.""" + ) -> dict[str, Any]: + """Make HTTP request(s) and return response data.""" ... - def _stream_class(self) -> type[Stream[Out, Params, Chunk]]: - """Return the Stream class for this client.""" - raise StreamingNotSupportedError(model_id=self.model.id) - def _make_stream_request( self, request_body: dict[str, Any], @@ -228,6 +266,10 @@ def _make_stream_request( """Make HTTP streaming request and return async iterator of events.""" raise StreamingNotSupportedError(model_id=self.model.id) + def _stream_class(self) -> type[Stream[Out, Params, Chunk]]: + """Return the Stream class for this client.""" + raise StreamingNotSupportedError(model_id=self.model.id) + def _validate_artifacts( self, inputs: In, @@ -241,6 +283,8 @@ def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: return { "model": self.model.id, "provider": self.provider, + "modality": self.modality, + "raw_response": response_data, } def _handle_error_response(self, response: httpx.Response) -> None: @@ -260,9 +304,9 @@ def _handle_error_response(self, response: httpx.Response) -> None: def _transform_output( self, - content: StructuredOutput, + content: TextContent, **parameters: Unpack[Params], # type: ignore[misc] - ) -> StructuredOutput: + ) -> TextContent: """Transform content using parameter mapper output transformations.""" for mapper in self.parameter_mappers(): value = parameters.get(mapper.name) @@ -270,60 +314,33 @@ def _transform_output( content = mapper.parse_output(content, value) return content + @abstractmethod + def _init_request(self, inputs: In) -> dict[str, Any]: + """Initialize provider-specific request structure from inputs.""" + ... + def _build_request( self, inputs: In, + extra_body: dict[str, Any] | None = None, + streaming: bool = False, **parameters: Unpack[Params], # type: ignore[misc] ) -> dict[str, Any]: """Build complete request by combining base request with parameters.""" + _ = streaming # Passed through to provider mixins request = self._init_request(inputs) for mapper in self.parameter_mappers(): value = parameters.get(mapper.name) request = mapper.map(request, value, self.model) - return request - - -_clients: dict[tuple[Capability, Provider], type[Client[Any, Any, Any]]] = {} - - -def register_client( - capability: Capability, - provider: Provider, - client_class: type[Client[Any, Any, Any]], -) -> 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[Any, Any, Any]]: - """Get the registered client class for a capability and provider. - - Args: - capability: The capability to get a client for. - provider: The provider to use. + if extra_body: + self._deep_merge(request, extra_body) - Returns: - The registered client class. - - Raises: - ClientNotFoundError: If no client is registered for this capability/provider. - """ - if (capability, provider) not in _clients: - raise ClientNotFoundError( - capability=capability, - provider=provider, - ) - return _clients[(capability, provider)] + return request -__all__ = ["APIMixin", "Client", "get_client_class", "register_client"] +__all__ = [ + "APIMixin", + "ModalityClient", +] diff --git a/src/celeste/constraints.py b/src/celeste/constraints.py index 491d8f74..c5820aee 100644 --- a/src/celeste/constraints.py +++ b/src/celeste/constraints.py @@ -7,9 +7,10 @@ from pydantic import BaseModel, Field, computed_field -from celeste.artifacts import ImageArtifact +from celeste.artifacts import AudioArtifact, ImageArtifact, VideoArtifact from celeste.exceptions import ConstraintViolationError -from celeste.mime_types import ImageMimeType +from celeste.mime_types import AudioMimeType, ImageMimeType, VideoMimeType +from celeste.types import AudioContent, ImageContent, VideoContent class Constraint(BaseModel, ABC): @@ -109,6 +110,71 @@ def __call__(self, value: str) -> str: return value +class Dimensions(Constraint): + """Dimension string constraint with pixel and aspect ratio bounds.""" + + min_pixels: int + max_pixels: int + min_aspect_ratio: float + max_aspect_ratio: float + presets: dict[str, str] | None = None + + def __call__(self, value: str) -> str: + """Validate dimension string against pixel and aspect ratio bounds.""" + if not isinstance(value, str): + msg = f"Must be string, got {type(value).__name__}" + raise ConstraintViolationError(msg) + + # Check if value is a preset key + if self.presets and value in self.presets: + actual_value = self.presets[value] + else: + actual_value = value + + # Parse dimension format "WIDTHxHEIGHT" + parts = actual_value.lower().split("x") + if len(parts) != 2: + msg = f"Invalid dimension format: {actual_value!r}. Expected 'WIDTHxHEIGHT'" + raise ConstraintViolationError(msg) + + # Validate parts are numeric + if not parts[0].isdigit() or not parts[1].isdigit(): + msg = ( + f"Invalid dimension format: {actual_value!r}. " + f"Width and height must be positive integers" + ) + raise ConstraintViolationError(msg) + + width = int(parts[0]) + height = int(parts[1]) + + # Validate dimensions are positive + if width <= 0 or height <= 0: + msg = f"Width and height must be positive, got {width}x{height}" + raise ConstraintViolationError(msg) + + # Validate total pixels + total_pixels = width * height + if not (self.min_pixels <= total_pixels <= self.max_pixels): + msg = ( + f"Total pixels {total_pixels:,} outside valid range " + f"[{self.min_pixels:,}, {self.max_pixels:,}]" + ) + raise ConstraintViolationError(msg) + + # Validate aspect ratio + aspect_ratio = width / height + if not (self.min_aspect_ratio <= aspect_ratio <= self.max_aspect_ratio): + msg = ( + f"Aspect ratio {aspect_ratio:.3f} outside valid range " + f"[{self.min_aspect_ratio:.3f}, {self.max_aspect_ratio:.3f}]" + ) + raise ConstraintViolationError(msg) + + # Return normalized format + return f"{width}x{height}" + + class Str(Constraint): """String type constraint with optional length validation.""" @@ -238,9 +304,7 @@ class ImagesConstraint(Constraint): max_count: int | None = None """Maximum number of images.""" - def __call__( - self, value: ImageArtifact | list[ImageArtifact] - ) -> list[ImageArtifact]: + def __call__(self, value: ImageContent) -> list[ImageArtifact]: """Validate image artifact(s) against constraint and normalize to list.""" # Normalize: if single ImageArtifact is passed, wrap it in a list images = value if isinstance(value, list) else [value] @@ -266,10 +330,139 @@ def __call__( return images +class VideoConstraint(Constraint): + """Constraint for validating a single video artifact - validates mime_type.""" + + supported_mime_types: list[VideoMimeType] | None = None + """Supported MIME types for the video.""" + + def __call__(self, value: VideoArtifact) -> VideoArtifact: + """Validate single video artifact against constraint.""" + if isinstance(value, list): + msg = "VideoConstraint requires a single VideoArtifact, not a list" + raise ConstraintViolationError(msg) + + if not isinstance(value, VideoArtifact): + msg = f"Must be VideoArtifact, got {type(value).__name__}" + raise ConstraintViolationError(msg) + + if ( + self.supported_mime_types is not None + and value.mime_type not in self.supported_mime_types + ): + supported_values = [mt.value for mt in self.supported_mime_types] + got_value = value.mime_type.value if value.mime_type else None + msg = f"mime_type must be one of {supported_values}, got {got_value!r}" + raise ConstraintViolationError(msg) + + return value + + +class VideosConstraint(Constraint): + """Constraint for validating video artifacts list - validates mime_type and count limits.""" + + supported_mime_types: list[VideoMimeType] | None = None + """Supported MIME types.""" + + max_count: int | None = None + """Maximum number of videos.""" + + def __call__(self, value: VideoContent) -> list[VideoArtifact]: + """Validate video artifact(s) against constraint and normalize to list.""" + # Normalize: if single VideoArtifact is passed, wrap it in a list + videos = value if isinstance(value, list) else [value] + + if self.max_count is not None and len(videos) > self.max_count: + msg = f"Must have at most {self.max_count} video(s), got {len(videos)}" + raise ConstraintViolationError(msg) + + if self.supported_mime_types is not None: + for i, vid in enumerate(videos): + if not isinstance(vid, VideoArtifact): + msg = f"Video {i + 1}: Must be VideoArtifact, got {type(vid).__name__}" + raise ConstraintViolationError(msg) + if vid.mime_type not in self.supported_mime_types: + supported_values = [mt.value for mt in self.supported_mime_types] + got_value = vid.mime_type.value if vid.mime_type else None + msg = ( + f"Video {i + 1}: mime_type must be one of {supported_values}, " + f"got {got_value!r}" + ) + raise ConstraintViolationError(msg) + + return videos + + +class AudioConstraint(Constraint): + """Constraint for validating a single audio artifact - validates mime_type.""" + + supported_mime_types: list[AudioMimeType] | None = None + """Supported MIME types for the audio.""" + + def __call__(self, value: AudioArtifact) -> AudioArtifact: + """Validate single audio artifact against constraint.""" + if isinstance(value, list): + msg = "AudioConstraint requires a single AudioArtifact, not a list" + raise ConstraintViolationError(msg) + + if not isinstance(value, AudioArtifact): + msg = f"Must be AudioArtifact, got {type(value).__name__}" + raise ConstraintViolationError(msg) + + if ( + self.supported_mime_types is not None + and value.mime_type not in self.supported_mime_types + ): + supported_values = [mt.value for mt in self.supported_mime_types] + got_value = value.mime_type.value if value.mime_type else None + msg = f"mime_type must be one of {supported_values}, got {got_value!r}" + raise ConstraintViolationError(msg) + + return value + + +class AudiosConstraint(Constraint): + """Constraint for validating audio artifacts list - validates mime_type and count limits.""" + + supported_mime_types: list[AudioMimeType] | None = None + """Supported MIME types.""" + + max_count: int | None = None + """Maximum number of audios.""" + + def __call__(self, value: AudioContent) -> list[AudioArtifact]: + """Validate audio artifact(s) against constraint and normalize to list.""" + # Normalize: if single AudioArtifact is passed, wrap it in a list + audios = value if isinstance(value, list) else [value] + + if self.max_count is not None and len(audios) > self.max_count: + msg = f"Must have at most {self.max_count} audio(s), got {len(audios)}" + raise ConstraintViolationError(msg) + + if self.supported_mime_types is not None: + for i, aud in enumerate(audios): + if not isinstance(aud, AudioArtifact): + msg = f"Audio {i + 1}: Must be AudioArtifact, got {type(aud).__name__}" + raise ConstraintViolationError(msg) + if aud.mime_type not in self.supported_mime_types: + supported_values = [mt.value for mt in self.supported_mime_types] + got_value = aud.mime_type.value if aud.mime_type else None + msg = ( + f"Audio {i + 1}: mime_type must be one of {supported_values}, " + f"got {got_value!r}" + ) + raise ConstraintViolationError(msg) + + return audios + + __all__ = [ + "AudioConstraint", + "AudiosConstraint", "Bool", "Choice", "Constraint", + "Dimensions", "Float", "ImageConstraint", "ImagesConstraint", @@ -278,4 +471,6 @@ def __call__( "Range", "Schema", "Str", + "VideoConstraint", + "VideosConstraint", ] diff --git a/src/celeste/core.py b/src/celeste/core.py index 804e7fa6..cb685f91 100644 --- a/src/celeste/core.py +++ b/src/celeste/core.py @@ -11,8 +11,10 @@ class Provider(StrEnum): BFL = "bfl" GOOGLE = "google" MISTRAL = "mistral" + MOONSHOT = "moonshot" COHERE = "cohere" XAI = "xai" + DEEPSEEK = "deepseek" HUGGINGFACE = "huggingface" REPLICATE = "replicate" STABILITYAI = "stabilityai" @@ -21,19 +23,52 @@ class Provider(StrEnum): PERPLEXITY = "perplexity" BYTEPLUS = "byteplus" ELEVENLABS = "elevenlabs" + GROQ = "groq" GRADIUM = "gradium" +class Modality(StrEnum): + """Supported modalities.""" + + TEXT = "text" + EMBEDDINGS = "embeddings" + IMAGES = "images" + VIDEOS = "videos" + AUDIO = "audio" + + +class Operation(StrEnum): + """All operations across all modalities. + + Individual modalities define which subset they support via + VALID_*_OPERATIONS frozensets and *Operation Literal types. + """ + + GENERATE = "generate" + EDIT = "edit" + ANALYZE = "analyze" + SPEAK = "speak" + TRANSCRIBE = "transcribe" + EMBED = "embed" + UPSCALE = "upscale" + + class Capability(StrEnum): - """Supported AI capabilities.""" + """Supported AI capabilities. + + .. deprecated:: + Use :class:`Modality` and :class:`Operation` instead. + This enum is kept for backward compatibility. + """ # Text TEXT_GENERATION = "text-generation" - EMBEDDINGS = "embeddings" + TEXT_EMBEDDINGS = "text-embeddings" # Image IMAGE_GENERATION = "image-generation" IMAGE_INTELLIGENCE = "image-intelligence" + IMAGE_EDIT = "image-edit" # Video VIDEO_INTELLIGENCE = "video-intelligence" @@ -86,4 +121,51 @@ class UsageField(StrEnum): CACHE_READ_INPUT_TOKENS = "cache_read_input_tokens" -__all__ = ["Capability", "InputType", "Parameter", "Provider", "UsageField"] +class Domain(StrEnum): + """Semantic grouping of operations by resource type. + + Domain represents what you work with (input type), while + Modality represents what you produce (output type). + """ + + TEXT = "text" + IMAGES = "images" + AUDIO = "audio" + VIDEOS = "videos" + + +# (Domain, Operation) → Modality inference +DOMAIN_OPERATION_TO_MODALITY: dict[tuple[Domain, Operation], Modality] = { + (Domain.TEXT, Operation.GENERATE): Modality.TEXT, + (Domain.TEXT, Operation.EMBED): Modality.EMBEDDINGS, + (Domain.IMAGES, Operation.GENERATE): Modality.IMAGES, + (Domain.IMAGES, Operation.EDIT): Modality.IMAGES, + (Domain.IMAGES, Operation.ANALYZE): Modality.TEXT, + (Domain.AUDIO, Operation.SPEAK): Modality.AUDIO, + (Domain.AUDIO, Operation.ANALYZE): Modality.TEXT, + (Domain.VIDEOS, Operation.GENERATE): Modality.VIDEOS, + (Domain.VIDEOS, Operation.ANALYZE): Modality.TEXT, +} + + +def infer_modality(domain: Domain, operation: Operation) -> Modality: + """Infer output modality from domain and operation.""" + key = (domain, operation) + if key not in DOMAIN_OPERATION_TO_MODALITY: + msg = f"No modality mapping for domain={domain.value}, operation={operation.value}" + raise ValueError(msg) + return DOMAIN_OPERATION_TO_MODALITY[key] + + +__all__ = [ + "DOMAIN_OPERATION_TO_MODALITY", + "Capability", + "Domain", + "InputType", + "Modality", + "Operation", + "Parameter", + "Provider", + "UsageField", + "infer_modality", +] diff --git a/src/celeste/credentials.py b/src/celeste/credentials.py index ff7260be..bb9e1e13 100644 --- a/src/celeste/credentials.py +++ b/src/celeste/credentials.py @@ -4,50 +4,53 @@ from pydantic import Field, SecretStr from pydantic_settings import BaseSettings -from celeste.auth import APIKey, Authentication +from celeste.auth import ( + Authentication, + AuthHeader, +) from celeste.core import Provider from celeste.exceptions import MissingCredentialsError, UnsupportedProviderError -# Provider to auth configuration mapping -# Maps provider to (package_name, header, prefix) for API key auth -PROVIDER_AUTH_CONFIG: dict[Provider, tuple[str, str, str]] = { - Provider.OPENAI: ("celeste_openai", "Authorization", "Bearer "), - Provider.ANTHROPIC: ("celeste_anthropic", "x-api-key", ""), - Provider.GOOGLE: ("celeste_google", "x-goog-api-key", ""), - Provider.MISTRAL: ("celeste_mistral", "Authorization", "Bearer "), - Provider.HUGGINGFACE: ("celeste_huggingface", "Authorization", "Bearer "), - Provider.STABILITYAI: ("celeste_stabilityai", "Authorization", "Bearer "), - Provider.REPLICATE: ("celeste_replicate", "Authorization", "Bearer "), - Provider.COHERE: ("celeste_cohere", "Authorization", "bearer "), - Provider.XAI: ("celeste_xai", "Authorization", "Bearer "), - Provider.LUMA: ("celeste_luma", "Authorization", "Bearer "), - Provider.TOPAZLABS: ("celeste_topazlabs", "X-API-Key", ""), - Provider.PERPLEXITY: ("celeste_perplexity", "Authorization", "Bearer "), - Provider.BYTEPLUS: ("celeste_byteplus", "Authorization", "Bearer "), - Provider.ELEVENLABS: ("celeste_elevenlabs", "xi-api-key", ""), - Provider.BFL: ("celeste_bfl", "x-key", ""), - Provider.GRADIUM: ("celeste_gradium", "x-api-key", ""), -} - -# 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", - Provider.BYTEPLUS: "byteplus_api_key", - Provider.ELEVENLABS: "elevenlabs_api_key", - Provider.BFL: "bfl_api_key", - Provider.GRADIUM: "gradium_api_key", -} +# Auth registry - populated by provider packages via register_auth() +# Maps provider to either: +# - tuple[str, str, str] for API key auth (secret_name, header, prefix) +# - type[Authentication] for custom auth classes (GoogleADC, OAuth, etc.) +_auth_registry: dict[Provider, tuple[str, str, str] | type[Authentication]] = {} + + +def register_auth( + provider: Provider, + *, + secret_name: str | None = None, + header: str | None = None, + prefix: str | None = None, + auth_class: type[Authentication] | None = None, +) -> None: + """Register auth for a provider.""" + if auth_class is not None: + _auth_registry[provider] = auth_class + elif secret_name is not None and header is not None and prefix is not None: + _auth_registry[provider] = (secret_name, header, prefix) + else: + msg = "Provide auth_class OR (secret_name, header, prefix)" + raise ValueError(msg) + + +def get_auth_config( + provider: Provider, +) -> tuple[str, str, str] | type[Authentication]: + """Get registered auth config for a provider. + + Returns: + Tuple of (secret_name, header, prefix) or an Authentication class. + + Raises: + UnsupportedProviderError: If provider has no registered auth config. + """ + if provider in _auth_registry: + return _auth_registry[provider] + + raise UnsupportedProviderError(provider=provider) class Credentials(BaseSettings): @@ -57,17 +60,20 @@ class Credentials(BaseSettings): 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") + moonshot_api_key: SecretStr | None = Field(None, alias="MOONSHOT_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") + deepseek_api_key: SecretStr | None = Field(None, alias="DEEPSEEK_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") byteplus_api_key: SecretStr | None = Field(None, alias="BYTEPLUS_API_KEY") elevenlabs_api_key: SecretStr | None = Field(None, alias="ELEVENLABS_API_KEY") bfl_api_key: SecretStr | None = Field(None, alias="BFL_API_KEY") + groq_api_key: SecretStr | None = Field(None, alias="GROQ_API_KEY") gradium_api_key: SecretStr | None = Field(None, alias="GRADIUM_API_KEY") model_config = { @@ -97,40 +103,46 @@ def get_credentials( return SecretStr(override_key) return override_key - if not self.has_credential(provider): + registered = _auth_registry.get(provider) + if registered is None: + raise UnsupportedProviderError(provider=provider) + + # Auth class doesn't use API keys + if isinstance(registered, type): + msg = f"{provider} uses auth class, not API key" + raise ValueError(msg) + + secret_name, _, _ = registered + field_name = secret_name.lower() + + credential: SecretStr | None = getattr(self, field_name, None) + if credential is None: raise MissingCredentialsError(provider=provider) - 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. - """ + """List all providers that have credentials configured.""" return [ provider - for provider in PROVIDER_CREDENTIAL_MAP - if self.has_credential(provider) + for provider in Provider + if provider in _auth_registry and self.has_credential(provider) ] def has_credential(self, provider: Provider) -> bool: - """Check if a specific provider has credentials configured. + """Check if a specific provider has credentials configured.""" + registered = _auth_registry.get(provider) + if registered is None: + raise UnsupportedProviderError(provider=provider) - Args: - provider: The AI provider to check. + # Auth class is always "configured" (uses ADC/OAuth) + if isinstance(registered, type): + return True - Returns: - True if provider has credentials configured, False if credentials not set. + secret_name, _, _ = registered + field_name = secret_name.lower() - Raises: - UnsupportedProviderError: If provider has no credential mapping. - """ - credential_field = PROVIDER_CREDENTIAL_MAP.get(provider) - if not credential_field: - raise UnsupportedProviderError(provider=provider) - return getattr(self, credential_field, None) is not None + return getattr(self, field_name, None) is not None def get_auth( self, @@ -152,28 +164,28 @@ def get_auth( MissingCredentialsError: If provider requires credentials but none configured. UnsupportedProviderError: If provider has no auth configuration. """ - # Direct auth object takes precedence if override_auth is not None: return override_auth - # Get auth config for provider - auth_config = PROVIDER_AUTH_CONFIG.get(provider) - if not auth_config: + registered = _auth_registry.get(provider) + if registered is None: raise UnsupportedProviderError(provider=provider) - # API key authentication - _package_name, header, prefix = auth_config + # Auth class (GoogleADC, OAuth, etc.) → instantiate + if isinstance(registered, type): + return registered() - # Get API key (override or from environment) + # API key config tuple → AuthHeader + _secret_name, header, prefix = registered api_key = self.get_credentials(provider, override_key) - - return APIKey( - key=api_key, - header=header, - prefix=prefix, - ) + return AuthHeader(secret=api_key, header=header, prefix=prefix) credentials = Credentials.model_validate({}) -__all__ = ["PROVIDER_AUTH_CONFIG", "Credentials", "credentials"] +__all__ = [ + "Credentials", + "credentials", + "get_auth_config", + "register_auth", +] diff --git a/src/celeste/exceptions.py b/src/celeste/exceptions.py index c4768dae..08f6cb5f 100644 --- a/src/celeste/exceptions.py +++ b/src/celeste/exceptions.py @@ -21,6 +21,7 @@ def __init__( model_id: str | None = None, provider: str | None = None, capability: str | None = None, + modality: str | None = None, ) -> None: """Initialize with model details. @@ -28,14 +29,20 @@ def __init__( model_id: Optional specific model ID that was not found. provider: Optional provider name. capability: Optional capability name (used when no specific model_id). + modality: Optional modality name (used when no specific model_id). """ self.model_id = model_id self.provider = provider self.capability = capability + self.modality = modality # Generate appropriate error message based on available parameters if model_id and provider: msg = f"Model '{model_id}' not found for provider {provider}" + elif modality and provider: + msg = f"No model found for modality '{modality}' with provider {provider}" + elif modality: + msg = f"No model found for modality '{modality}'" elif capability and provider: msg = ( f"No model found for capability '{capability}' with provider {provider}" @@ -73,15 +80,49 @@ class ClientError(Error): class ClientNotFoundError(ClientError): - """Raised when no client is registered for a capability/provider combination.""" + """Raised when no client is registered for a capability/modality/provider combination.""" - def __init__(self, capability: str, provider: str) -> None: - """Initialize with capability and provider details.""" + def __init__( + self, + capability: str | None = None, + provider: str | None = None, + modality: str | None = None, + operation: str | None = None, + ) -> None: + """Initialize with capability/modality and provider details.""" self.capability = capability self.provider = provider - super().__init__( - f"No client registered for {capability} with provider {provider}" - ) + self.modality = modality + self.operation = operation + + # Build message based on available parameters + if modality and operation and provider: + msg = f"No client registered for modality '{modality}', operation '{operation}' with provider {provider}" + elif modality and provider: + msg = f"No client registered for modality '{modality}' with provider {provider}" + elif modality: + msg = f"No client registered for modality '{modality}'" + elif capability and provider: + msg = f"No client registered for {capability} with provider {provider}" + else: + msg = "No client registered" + + super().__init__(msg) + + +class ModalityNotFoundError(ClientError): + """Raised when no modality client is registered for a modality/provider combination.""" + + def __init__(self, modality: str, provider: str | None = None) -> None: + """Initialize with modality and provider details.""" + self.modality = modality + self.provider = provider + if provider: + super().__init__( + f"No client registered for modality '{modality}' with provider {provider}" + ) + else: + super().__init__(f"No client registered for modality '{modality}'") class StreamingError(Error): @@ -176,6 +217,7 @@ def __init__(self, parameter: str, model_id: str) -> None: "ConstraintViolationError", "Error", "MissingCredentialsError", + "ModalityNotFoundError", "ModelNotFoundError", "StreamEmptyError", "StreamNotExhaustedError", diff --git a/src/celeste/http.py b/src/celeste/http.py index c267d87e..c4e6cb5b 100644 --- a/src/celeste/http.py +++ b/src/celeste/http.py @@ -1,5 +1,6 @@ """HTTP client with persistent connection pooling for AI provider APIs.""" +import asyncio import json import logging from collections.abc import AsyncIterator @@ -8,7 +9,7 @@ import httpx from httpx_sse import aconnect_sse -from celeste.core import Capability, Provider +from celeste.core import Modality, Provider logger = logging.getLogger(__name__) @@ -32,17 +33,26 @@ def __init__( max_keepalive_connections: Maximum idle keepalive connections. """ self._client: httpx.AsyncClient | None = None + self._client_loop: int | 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.""" + current_loop = asyncio.get_running_loop() + + # Recreate client if event loop changed (prevents "Event loop is closed" errors) + if self._client is not None and self._client_loop != id(current_loop): + self._client = None + 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 + self._client_loop = id(current_loop) + return self._client async def post( @@ -197,24 +207,22 @@ async def __aexit__(self, *args: Any) -> None: # noqa: ANN401 # Module-level registry of shared HTTPClient instances -_http_clients: dict[tuple[Provider, Capability], HTTPClient] = {} +_http_clients: dict[tuple[Provider, Modality], HTTPClient] = {} -def get_http_client(provider: Provider, capability: Capability) -> HTTPClient: - """Get or create shared HTTP client for provider and capability combination. +def get_http_client(provider: Provider, modality: Modality) -> HTTPClient: + """Get or create shared HTTP client for provider and modality combination. Args: provider: The AI provider. - capability: The capability being used. + modality: The modality being used. Returns: - Shared HTTPClient instance for this provider and capability. + Shared HTTPClient instance for this provider and modality. """ - key = (provider, capability) - + key = (provider, modality) if key not in _http_clients: _http_clients[key] = HTTPClient() - return _http_clients[key] diff --git a/src/celeste/io.py b/src/celeste/io.py index d71912d0..8b3987b3 100644 --- a/src/celeste/io.py +++ b/src/celeste/io.py @@ -8,7 +8,7 @@ from celeste.artifacts import AudioArtifact, ImageArtifact, VideoArtifact from celeste.constraints import Constraint -from celeste.core import Capability, InputType +from celeste.core import InputType class Input(BaseModel): @@ -47,22 +47,6 @@ class Chunk[Content](BaseModel): metadata: dict[str, Any] = Field(default_factory=dict) -_inputs: dict[Capability, type[Input]] = {} - - -def register_input(capability: Capability, input_class: type[Input]) -> None: - """Register an Input class for a capability.""" - _inputs[capability] = input_class - - -def get_input_class(capability: Capability) -> type[Input]: - """Get the registered Input class for a capability.""" - if capability not in _inputs: - msg = f"No Input class registered for capability: {capability}" - raise KeyError(msg) - return _inputs[capability] - - # Centralized mapping: field type → InputType INPUT_TYPE_MAPPING: dict[type, InputType] = { str: InputType.TEXT, @@ -72,26 +56,6 @@ def get_input_class(capability: Capability) -> type[Input]: } -def get_required_input_types(capability: Capability) -> set[InputType]: - """Derive required input types from Input class fields. - - Introspects the Input class registered for a capability and returns - the set of InputTypes based on field annotations. - - Args: - capability: The capability to get required input types for. - - Returns: - Set of InputType values required by the capability's Input class. - """ - input_class = get_input_class(capability) - return { - INPUT_TYPE_MAPPING[field.annotation] - for field in input_class.model_fields.values() - if field.annotation in INPUT_TYPE_MAPPING - } - - def _extract_input_type(param_type: type) -> InputType | None: """Extract InputType from a type, handling unions and generics. @@ -144,7 +108,4 @@ def get_constraint_input_type(constraint: Constraint) -> InputType | None: "Output", "Usage", "get_constraint_input_type", - "get_input_class", - "get_required_input_types", - "register_input", ] diff --git a/src/celeste/mime_types.py b/src/celeste/mime_types.py index 8049d8ac..70f41d03 100644 --- a/src/celeste/mime_types.py +++ b/src/celeste/mime_types.py @@ -11,6 +11,7 @@ class ApplicationMimeType(MimeType): """Standard MIME types for application data.""" JSON = "application/json" + OCTET_STREAM = "application/octet-stream" class ImageMimeType(MimeType): diff --git a/src/celeste/modalities/__init__.py b/src/celeste/modalities/__init__.py new file mode 100644 index 00000000..9ddeab7f --- /dev/null +++ b/src/celeste/modalities/__init__.py @@ -0,0 +1,5 @@ +"""Celeste modalities.""" + +from celeste.modalities import audio, embeddings, images, text, videos + +__all__ = ["audio", "embeddings", "images", "text", "videos"] diff --git a/src/celeste/modalities/audio/__init__.py b/src/celeste/modalities/audio/__init__.py new file mode 100644 index 00000000..c2e6fb97 --- /dev/null +++ b/src/celeste/modalities/audio/__init__.py @@ -0,0 +1,22 @@ +"""Celeste Audio modality.""" + +from .client import AudioClient +from .io import ( + AudioChunk, + AudioFinishReason, + AudioInput, + AudioOutput, + AudioUsage, +) +from .parameters import AudioParameter, AudioParameters + +__all__ = [ + "AudioChunk", + "AudioClient", + "AudioFinishReason", + "AudioInput", + "AudioOutput", + "AudioParameter", + "AudioParameters", + "AudioUsage", +] diff --git a/src/celeste/modalities/audio/client.py b/src/celeste/modalities/audio/client.py new file mode 100644 index 00000000..6c6f45b8 --- /dev/null +++ b/src/celeste/modalities/audio/client.py @@ -0,0 +1,110 @@ +"""Audio modality client.""" + +from typing import Unpack + +from asgiref.sync import async_to_sync + +from celeste.client import ModalityClient +from celeste.core import Modality +from celeste.types import AudioContent + +from .io import AudioInput, AudioOutput +from .parameters import AudioParameters +from .streaming import AudioStream + + +class AudioClient( + ModalityClient[AudioInput, AudioOutput, AudioParameters, AudioContent] +): + """Base audio client. Providers implement speak() method.""" + + modality: Modality = Modality.AUDIO + + @classmethod + def _output_class(cls) -> type[AudioOutput]: + """Return the Output class for audio modality.""" + return AudioOutput + + @property + def stream(self) -> "AudioStreamNamespace": + """Streaming namespace for audio operations.""" + return AudioStreamNamespace(self) + + @property + def sync(self) -> "AudioSyncNamespace": + """Sync namespace for audio operations.""" + return AudioSyncNamespace(self) + + +class AudioStreamNamespace: + """Streaming namespace for audio operations.""" + + def __init__(self, client: AudioClient) -> None: + self._client = client + + def speak( + self, + text: str, + **parameters: Unpack[AudioParameters], + ) -> AudioStream: + """Stream speech generation.""" + inputs = AudioInput(text=text) + return self._client._stream( + inputs, + stream_class=self._client._stream_class(), + **parameters, + ) + + +class AudioSyncNamespace: + """Sync namespace for audio operations.""" + + def __init__(self, client: AudioClient) -> None: + self._client = client + + def speak( + self, + text: str, + **parameters: Unpack[AudioParameters], + ) -> AudioOutput: + """Blocking speech generation.""" + inputs = AudioInput(text=text) + return async_to_sync(self._client._predict)(inputs, **parameters) + + @property + def stream(self) -> "AudioSyncStreamNamespace": + """Sync streaming namespace.""" + return AudioSyncStreamNamespace(self._client) + + +class AudioSyncStreamNamespace: + """Sync streaming namespace - returns Stream instance with sync iteration support.""" + + def __init__(self, client: AudioClient) -> None: + self._client = client + + def speak( + self, + text: str, + **parameters: Unpack[AudioParameters], + ) -> AudioStream: + """Sync streaming speech generation. + + Returns Stream instance that supports both async and sync iteration. + + Usage: + stream = client.sync.stream.speak("Hello world") + for chunk in stream: # Sync iteration (bridges async internally) + audio_bytes = chunk.content + stream.output.content.save("output.mp3") + """ + # Return same stream as async version - __iter__/__next__ handle sync iteration + return self._client.stream.speak(text, **parameters) + + +__all__ = [ + "AudioClient", + "AudioStreamNamespace", + "AudioSyncNamespace", + "AudioSyncStreamNamespace", +] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/constraints.py b/src/celeste/modalities/audio/constraints.py similarity index 83% rename from packages/capabilities/speech-generation/src/celeste_speech_generation/constraints.py rename to src/celeste/modalities/audio/constraints.py index f7106960..6b53c16f 100644 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/constraints.py +++ b/src/celeste/modalities/audio/constraints.py @@ -1,16 +1,21 @@ -"""Constraint models for speech generation.""" +"""Constraint models for audio modality.""" from pydantic import Field from celeste.constraints import Constraint from celeste.exceptions import ConstraintViolationError -from celeste_speech_generation.voices import Voice + +from .voices import Voice class VoiceConstraint(Constraint): - """Voice constraint - value must be a valid voice ID from the provided voices.""" + """Voice constraint - value must be a valid voice ID from the provided voices. + + Accepts either voice ID or voice name and returns the canonical voice ID. + """ voices: list[Voice] = Field(min_length=1) + """List of valid voices for this constraint.""" def __call__(self, value: str) -> str: """Validate value is a valid voice ID or name and return the ID.""" diff --git a/src/celeste/modalities/audio/io.py b/src/celeste/modalities/audio/io.py new file mode 100644 index 00000000..68f207e8 --- /dev/null +++ b/src/celeste/modalities/audio/io.py @@ -0,0 +1,51 @@ +"""Input and output types for audio modality.""" + +from pydantic import Field + +from celeste.artifacts import AudioArtifact +from celeste.io import Chunk, FinishReason, Input, Output, Usage + + +class AudioInput(Input): + """Input for audio operations.""" + + text: str + + +class AudioFinishReason(FinishReason): + """Audio finish reason.""" + + reason: str | None = None + message: str | None = None + + +class AudioUsage(Usage): + """Audio usage metrics. + + All fields optional since providers vary. + """ + + +class AudioOutput(Output[AudioArtifact]): + """Output with audio artifact content.""" + + usage: AudioUsage = Field(default_factory=AudioUsage) + finish_reason: AudioFinishReason | None = None + + +class AudioChunk(Chunk[bytes]): + """Typed chunk for audio streaming. + + Audio streaming sends raw bytes without finish_reason. + """ + + usage: AudioUsage | None = None + + +__all__ = [ + "AudioChunk", + "AudioFinishReason", + "AudioInput", + "AudioOutput", + "AudioUsage", +] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/languages.py b/src/celeste/modalities/audio/languages.py similarity index 100% rename from packages/capabilities/speech-generation/src/celeste_speech_generation/languages.py rename to src/celeste/modalities/audio/languages.py diff --git a/src/celeste/modalities/audio/models.py b/src/celeste/modalities/audio/models.py new file mode 100644 index 00000000..e1668723 --- /dev/null +++ b/src/celeste/modalities/audio/models.py @@ -0,0 +1,15 @@ +"""Aggregated models for audio modality.""" + +from celeste.models import Model + +from .providers.elevenlabs.models import MODELS as ELEVENLABS_MODELS +from .providers.google.models import MODELS as GOOGLE_MODELS +from .providers.gradium.models import MODELS as GRADIUM_MODELS +from .providers.openai.models import MODELS as OPENAI_MODELS + +MODELS: list[Model] = [ + *ELEVENLABS_MODELS, + *GOOGLE_MODELS, + *GRADIUM_MODELS, + *OPENAI_MODELS, +] diff --git a/src/celeste/modalities/audio/parameters.py b/src/celeste/modalities/audio/parameters.py new file mode 100644 index 00000000..6589a371 --- /dev/null +++ b/src/celeste/modalities/audio/parameters.py @@ -0,0 +1,31 @@ +"""Parameters for audio modality.""" + +from enum import StrEnum + +from celeste.parameters import Parameters + + +class AudioParameter(StrEnum): + """Unified parameter names for audio modality.""" + + VOICE = "voice" + SPEED = "speed" + OUTPUT_FORMAT = "output_format" + PROMPT = "prompt" + LANGUAGE = "language" + + +class AudioParameters(Parameters): + """Parameters for audio operations.""" + + voice: str + speed: float + output_format: str + prompt: str + language: str + + +__all__ = [ + "AudioParameter", + "AudioParameters", +] diff --git a/src/celeste/modalities/audio/providers/__init__.py b/src/celeste/modalities/audio/providers/__init__.py new file mode 100644 index 00000000..e6c582df --- /dev/null +++ b/src/celeste/modalities/audio/providers/__init__.py @@ -0,0 +1,16 @@ +"""Audio providers.""" + +from celeste.core import Provider + +from ..client import AudioClient +from .elevenlabs import ElevenLabsAudioClient +from .google import GoogleAudioClient +from .gradium import GradiumAudioClient +from .openai import OpenAIAudioClient + +PROVIDERS: dict[Provider, type[AudioClient]] = { + Provider.ELEVENLABS: ElevenLabsAudioClient, + Provider.GOOGLE: GoogleAudioClient, + Provider.GRADIUM: GradiumAudioClient, + Provider.OPENAI: OpenAIAudioClient, +} diff --git a/src/celeste/modalities/audio/providers/elevenlabs/__init__.py b/src/celeste/modalities/audio/providers/elevenlabs/__init__.py new file mode 100644 index 00000000..7ec67063 --- /dev/null +++ b/src/celeste/modalities/audio/providers/elevenlabs/__init__.py @@ -0,0 +1,6 @@ +"""ElevenLabs provider for audio modality.""" + +from .client import ElevenLabsAudioClient +from .models import MODELS + +__all__ = ["MODELS", "ElevenLabsAudioClient"] diff --git a/src/celeste/modalities/audio/providers/elevenlabs/client.py b/src/celeste/modalities/audio/providers/elevenlabs/client.py new file mode 100644 index 00000000..89e96563 --- /dev/null +++ b/src/celeste/modalities/audio/providers/elevenlabs/client.py @@ -0,0 +1,144 @@ +"""ElevenLabs audio client.""" + +from typing import Any, Unpack + +from celeste.artifacts import AudioArtifact +from celeste.parameters import ParameterMapper +from celeste.providers.elevenlabs.text_to_speech import config +from celeste.providers.elevenlabs.text_to_speech.client import ( + ElevenLabsTextToSpeechClient as ElevenLabsTextToSpeechMixin, +) +from celeste.providers.elevenlabs.text_to_speech.streaming import ( + ElevenLabsTextToSpeechStream as _ElevenLabsTextToSpeechStream, +) + +from ...client import AudioClient +from ...io import ( + AudioChunk, + AudioFinishReason, + AudioInput, + AudioOutput, + AudioUsage, +) +from ...parameters import AudioParameters +from ...streaming import AudioStream +from .parameters import ELEVENLABS_PARAMETER_MAPPERS + + +class ElevenLabsAudioStream(_ElevenLabsTextToSpeechStream, AudioStream): + """ElevenLabs streaming for audio modality.""" + + def _parse_chunk_usage(self, event_data: dict[str, Any]) -> AudioUsage | None: + """Parse and wrap usage from event.""" + usage = super()._parse_chunk_usage(event_data) + if usage: + return AudioUsage(**usage) + return None + + def _parse_chunk_finish_reason( + self, event_data: dict[str, Any] + ) -> AudioFinishReason | None: + """Parse and wrap finish reason from event.""" + finish_reason = super()._parse_chunk_finish_reason(event_data) + if finish_reason: + return AudioFinishReason(reason=finish_reason.reason) + return None + + def _parse_chunk(self, event_data: dict[str, Any]) -> AudioChunk | None: + """Parse binary audio chunk from stream event.""" + chunk_data = self._parse_chunk_content(event_data) + if not chunk_data: + usage = self._parse_chunk_usage(event_data) + finish_reason = self._parse_chunk_finish_reason(event_data) + if usage is None and finish_reason is None: + return None + # Chunk with usage/finish_reason only (no audio) + return AudioChunk( + content=b"", + finish_reason=finish_reason, + usage=usage, + metadata={"event_data": event_data}, + ) + + return AudioChunk( + content=chunk_data, + finish_reason=self._parse_chunk_finish_reason(event_data), + usage=self._parse_chunk_usage(event_data), + metadata={"event_data": event_data}, + ) + + def _aggregate_content(self, chunks: list[AudioChunk]) -> AudioArtifact: + """Aggregate audio content from chunks into AudioArtifact.""" + audio_bytes = b"".join(chunk.content for chunk in chunks if chunk.content) + # Get mime_type from output_format parameter via client + output_format = self._parameters.get("output_format") + client: ElevenLabsAudioClient = self._client + mime_type = client._map_output_format_to_mime_type(output_format) + return AudioArtifact(data=audio_bytes, mime_type=mime_type) + + def _aggregate_event_data(self, chunks: list[AudioChunk]) -> list[dict[str, Any]]: + """Collect raw events (filtering happens in _build_stream_metadata).""" + events: list[dict[str, Any]] = [] + for chunk in chunks: + event_data = chunk.metadata.get("event_data") + if isinstance(event_data, dict): + events.append(event_data) + return events + + +class ElevenLabsAudioClient(ElevenLabsTextToSpeechMixin, AudioClient): + """ElevenLabs audio client (TTS).""" + + @classmethod + def parameter_mappers(cls) -> list[ParameterMapper]: + return ELEVENLABS_PARAMETER_MAPPERS + + async def speak( + self, + text: str, + **parameters: Unpack[AudioParameters], + ) -> AudioOutput: + """Convert text to speech audio.""" + inputs = AudioInput(text=text) + return await self._predict( + inputs, + endpoint=config.ElevenLabsTextToSpeechEndpoint.CREATE_SPEECH, + **parameters, + ) + + def _init_request(self, inputs: AudioInput) -> dict[str, Any]: + """Initialize request with text input.""" + return {"text": inputs.text} + + def _parse_usage(self, response_data: dict[str, Any]) -> AudioUsage: + """Parse usage from response.""" + usage = super()._parse_usage(response_data) + return AudioUsage(**usage) + + def _parse_content( + self, + response_data: dict[str, Any], + **parameters: Unpack[AudioParameters], + ) -> AudioArtifact: + """Extract audio bytes from response.""" + audio_bytes = response_data.get("audio_bytes") + if not audio_bytes: + msg = "No audio data in response" + raise ValueError(msg) + + output_format = parameters.get("output_format") + mime_type = self._map_output_format_to_mime_type(output_format) + + return AudioArtifact(data=audio_bytes, mime_type=mime_type) + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> AudioFinishReason: + """Parse finish reason from response.""" + finish_reason = super()._parse_finish_reason(response_data) + return AudioFinishReason(reason=finish_reason.reason) + + def _stream_class(self) -> type[AudioStream]: + """Return the Stream class for this provider.""" + return ElevenLabsAudioStream + + +__all__ = ["ElevenLabsAudioClient", "ElevenLabsAudioStream"] diff --git a/src/celeste/modalities/audio/providers/elevenlabs/models.py b/src/celeste/modalities/audio/providers/elevenlabs/models.py new file mode 100644 index 00000000..b1cd3c61 --- /dev/null +++ b/src/celeste/modalities/audio/providers/elevenlabs/models.py @@ -0,0 +1,134 @@ +"""ElevenLabs models for audio modality.""" + +from celeste.constraints import Choice, Range +from celeste.core import Modality, Operation, Provider +from celeste.models import Model + +from ...constraints import VoiceConstraint +from ...languages import Language +from ...parameters import AudioParameter +from .voices import ELEVENLABS_VOICES + +# Valid output formats for ElevenLabs API +ELEVENLABS_OUTPUT_FORMATS = [ + "mp3_22050_32", + "mp3_44100_32", + "mp3_44100_64", + "mp3_44100_96", + "mp3_44100_128", + "mp3_44100_192", + "pcm_8000", + "pcm_16000", + "pcm_22050", + "pcm_24000", + "pcm_44100", + "pcm_48000", + "ulaw_8000", + "alaw_8000", + "opus_48000_32", + "opus_48000_64", + "opus_48000_96", + "opus_48000_128", + "opus_48000_192", +] + +MODELS: list[Model] = [ + Model( + id="eleven_v3", + provider=Provider.ELEVENLABS, + display_name="Eleven v3 (Alpha)", + streaming=False, + operations={Modality.AUDIO: {Operation.SPEAK}}, + parameter_constraints={ + AudioParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), + AudioParameter.SPEED: Range(min=0.7, max=1.2), + AudioParameter.OUTPUT_FORMAT: Choice(options=ELEVENLABS_OUTPUT_FORMATS), + }, + ), + Model( + id="eleven_multilingual_v2", + provider=Provider.ELEVENLABS, + display_name="Eleven Multilingual v2", + streaming=False, + operations={Modality.AUDIO: {Operation.SPEAK}}, + parameter_constraints={ + AudioParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), + AudioParameter.SPEED: Range(min=0.7, max=1.2), + AudioParameter.OUTPUT_FORMAT: Choice(options=ELEVENLABS_OUTPUT_FORMATS), + }, + ), + Model( + id="eleven_turbo_v2_5", + provider=Provider.ELEVENLABS, + display_name="Eleven Turbo v2.5", + streaming=True, + operations={Modality.AUDIO: {Operation.SPEAK}}, + parameter_constraints={ + AudioParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), + AudioParameter.SPEED: Range(min=0.7, max=1.2), + AudioParameter.LANGUAGE: Choice(options=list(Language)), + AudioParameter.OUTPUT_FORMAT: Choice(options=ELEVENLABS_OUTPUT_FORMATS), + }, + ), + Model( + id="eleven_turbo_v2", + provider=Provider.ELEVENLABS, + display_name="Eleven Turbo v2", + streaming=True, + operations={Modality.AUDIO: {Operation.SPEAK}}, + parameter_constraints={ + AudioParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), + AudioParameter.SPEED: Range(min=0.7, max=1.2), + AudioParameter.OUTPUT_FORMAT: Choice(options=ELEVENLABS_OUTPUT_FORMATS), + }, + ), + Model( + id="eleven_flash_v2_5", + provider=Provider.ELEVENLABS, + display_name="Eleven Flash v2.5", + streaming=True, + operations={Modality.AUDIO: {Operation.SPEAK}}, + parameter_constraints={ + AudioParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), + AudioParameter.SPEED: Range(min=0.7, max=1.2), + AudioParameter.LANGUAGE: Choice(options=list(Language)), + AudioParameter.OUTPUT_FORMAT: Choice(options=ELEVENLABS_OUTPUT_FORMATS), + }, + ), + Model( + id="eleven_flash_v2", + provider=Provider.ELEVENLABS, + display_name="Eleven Flash v2", + streaming=True, + operations={Modality.AUDIO: {Operation.SPEAK}}, + parameter_constraints={ + AudioParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), + AudioParameter.SPEED: Range(min=0.7, max=1.2), + AudioParameter.OUTPUT_FORMAT: Choice(options=ELEVENLABS_OUTPUT_FORMATS), + }, + ), + Model( + id="eleven_multilingual_v1", + provider=Provider.ELEVENLABS, + display_name="Eleven Multilingual v1", + streaming=True, + operations={Modality.AUDIO: {Operation.SPEAK}}, + parameter_constraints={ + AudioParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), + AudioParameter.SPEED: Range(min=0.7, max=1.2), + AudioParameter.OUTPUT_FORMAT: Choice(options=ELEVENLABS_OUTPUT_FORMATS), + }, + ), + Model( + id="eleven_monolingual_v1", + provider=Provider.ELEVENLABS, + display_name="Eleven English v1", + streaming=True, + operations={Modality.AUDIO: {Operation.SPEAK}}, + parameter_constraints={ + AudioParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), + AudioParameter.SPEED: Range(min=0.7, max=1.2), + AudioParameter.OUTPUT_FORMAT: Choice(options=ELEVENLABS_OUTPUT_FORMATS), + }, + ), +] diff --git a/src/celeste/modalities/audio/providers/elevenlabs/parameters.py b/src/celeste/modalities/audio/providers/elevenlabs/parameters.py new file mode 100644 index 00000000..479a0d2d --- /dev/null +++ b/src/celeste/modalities/audio/providers/elevenlabs/parameters.py @@ -0,0 +1,51 @@ +"""ElevenLabs parameter mappers for audio modality.""" + +from celeste.parameters import ParameterMapper +from celeste.providers.elevenlabs.text_to_speech.parameters import ( + LanguageCodeMapper as _LanguageCodeMapper, +) +from celeste.providers.elevenlabs.text_to_speech.parameters import ( + OutputFormatMapper as _OutputFormatMapper, +) +from celeste.providers.elevenlabs.text_to_speech.parameters import ( + SpeedMapper as _SpeedMapper, +) +from celeste.providers.elevenlabs.text_to_speech.parameters import ( + VoiceMapper as _VoiceMapper, +) + +from ...parameters import AudioParameter + + +class VoiceMapper(_VoiceMapper): + """Map voice to ElevenLabs voice_id parameter.""" + + name = AudioParameter.VOICE + + +class SpeedMapper(_SpeedMapper): + """Map speed to ElevenLabs voice_settings.speed parameter.""" + + name = AudioParameter.SPEED + + +class OutputFormatMapper(_OutputFormatMapper): + """Map output_format to ElevenLabs output_format parameter.""" + + name = AudioParameter.OUTPUT_FORMAT + + +class LanguageCodeMapper(_LanguageCodeMapper): + """Map language to ElevenLabs language_code parameter.""" + + name = AudioParameter.LANGUAGE + + +ELEVENLABS_PARAMETER_MAPPERS: list[ParameterMapper] = [ + VoiceMapper(), + SpeedMapper(), + OutputFormatMapper(), + LanguageCodeMapper(), +] + +__all__ = ["ELEVENLABS_PARAMETER_MAPPERS"] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/voices.py b/src/celeste/modalities/audio/providers/elevenlabs/voices.py similarity index 98% rename from packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/voices.py rename to src/celeste/modalities/audio/providers/elevenlabs/voices.py index b6337e3c..d0635a62 100644 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/voices.py +++ b/src/celeste/modalities/audio/providers/elevenlabs/voices.py @@ -1,7 +1,8 @@ -"""ElevenLabs voice definitions for speech generation.""" +"""ElevenLabs voice definitions for audio modality.""" -from celeste import Provider -from celeste_speech_generation.voices import Voice +from celeste.core import Provider + +from ...voices import Voice # ElevenLabs default voices (from API: GET /v2/voices?voice_type=default) ELEVENLABS_VOICES = [ diff --git a/src/celeste/modalities/audio/providers/google/__init__.py b/src/celeste/modalities/audio/providers/google/__init__.py new file mode 100644 index 00000000..92ae8df3 --- /dev/null +++ b/src/celeste/modalities/audio/providers/google/__init__.py @@ -0,0 +1,5 @@ +"""Google provider for audio modality.""" + +from .client import GoogleAudioClient + +__all__ = ["GoogleAudioClient"] diff --git a/src/celeste/modalities/audio/providers/google/client.py b/src/celeste/modalities/audio/providers/google/client.py new file mode 100644 index 00000000..f78aed5c --- /dev/null +++ b/src/celeste/modalities/audio/providers/google/client.py @@ -0,0 +1,78 @@ +"""Google audio client.""" + +import base64 +from typing import Any, Unpack + +from celeste.artifacts import AudioArtifact +from celeste.mime_types import AudioMimeType +from celeste.parameters import ParameterMapper +from celeste.providers.google.cloud_tts import config +from celeste.providers.google.cloud_tts.client import ( + GoogleCloudTTSClient as GoogleCloudTTSMixin, +) + +from ...client import AudioClient +from ...io import ( + AudioFinishReason, + AudioInput, + AudioOutput, + AudioUsage, +) +from ...parameters import AudioParameter, AudioParameters +from .parameters import GOOGLE_PARAMETER_MAPPERS + + +class GoogleAudioClient(GoogleCloudTTSMixin, AudioClient): + """Google audio client (Cloud TTS).""" + + @classmethod + def parameter_mappers(cls) -> list[ParameterMapper]: + return GOOGLE_PARAMETER_MAPPERS + + async def speak( + self, + text: str, + **parameters: Unpack[AudioParameters], + ) -> AudioOutput: + """Convert text to speech audio.""" + inputs = AudioInput(text=text) + return await self._predict( + inputs, + endpoint=config.GoogleCloudTTSEndpoint.CREATE_SPEECH, + **parameters, + ) + + def _init_request(self, inputs: AudioInput) -> dict[str, Any]: + """Initialize request with text input.""" + return { + "input": {"text": inputs.text}, + "voice": {"modelName": self.model.id}, + "audioConfig": {}, + } + + def _parse_usage(self, response_data: dict[str, Any]) -> AudioUsage: + """Parse usage from response.""" + usage = super()._parse_usage(response_data) + return AudioUsage(**usage) + + def _parse_content( + self, + response_data: dict[str, Any], + **parameters: Unpack[AudioParameters], + ) -> AudioArtifact: + """Extract audio bytes from response.""" + audio_b64 = super()._parse_content(response_data) + audio_bytes = base64.b64decode(audio_b64) + + output_format = parameters.get(AudioParameter.OUTPUT_FORMAT) + mime_type = AudioMimeType(output_format) if output_format else AudioMimeType.MP3 + + return AudioArtifact(data=audio_bytes, mime_type=mime_type) + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> AudioFinishReason: + """Parse finish reason from response.""" + finish_reason = super()._parse_finish_reason(response_data) + return AudioFinishReason(reason=finish_reason.reason) + + +__all__ = ["GoogleAudioClient"] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/models.py b/src/celeste/modalities/audio/providers/google/models.py similarity index 52% rename from packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/models.py rename to src/celeste/modalities/audio/providers/google/models.py index c2c9cdde..6185b73b 100644 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/models.py +++ b/src/celeste/modalities/audio/providers/google/models.py @@ -1,12 +1,13 @@ -"""Google TTS models for speech generation.""" +"""Google TTS models for audio modality.""" -from celeste import Model, Provider -from celeste.constraints import Choice, Str +from celeste.constraints import Choice +from celeste.core import Modality, Operation, Provider from celeste.mime_types import AudioMimeType -from celeste_speech_generation.constraints import VoiceConstraint -from celeste_speech_generation.languages import Language -from celeste_speech_generation.parameters import SpeechGenerationParameter +from celeste.models import Model +from ...constraints import VoiceConstraint +from ...languages import Language +from ...parameters import AudioParameter from .voices import GOOGLE_VOICES # Supported output formats for Google TTS @@ -47,15 +48,11 @@ provider=Provider.GOOGLE, display_name="Google TTS Gemini 2.5 Flash", streaming=False, + operations={Modality.AUDIO: {Operation.SPEAK}}, parameter_constraints={ - SpeechGenerationParameter.VOICE: VoiceConstraint(voices=GOOGLE_VOICES), - SpeechGenerationParameter.LANGUAGE: Choice( - options=GOOGLE_SUPPORTED_LANGUAGES - ), - SpeechGenerationParameter.PROMPT: Str(), - SpeechGenerationParameter.OUTPUT_FORMAT: Choice( - options=GOOGLE_SUPPORTED_FORMATS - ), + AudioParameter.VOICE: VoiceConstraint(voices=GOOGLE_VOICES), + AudioParameter.LANGUAGE: Choice(options=GOOGLE_SUPPORTED_LANGUAGES), + AudioParameter.OUTPUT_FORMAT: Choice(options=GOOGLE_SUPPORTED_FORMATS), }, ), Model( @@ -63,15 +60,11 @@ provider=Provider.GOOGLE, display_name="Google TTS Gemini 2.5 Pro", streaming=False, + operations={Modality.AUDIO: {Operation.SPEAK}}, parameter_constraints={ - SpeechGenerationParameter.VOICE: VoiceConstraint(voices=GOOGLE_VOICES), - SpeechGenerationParameter.LANGUAGE: Choice( - options=GOOGLE_SUPPORTED_LANGUAGES - ), - SpeechGenerationParameter.PROMPT: Str(), - SpeechGenerationParameter.OUTPUT_FORMAT: Choice( - options=GOOGLE_SUPPORTED_FORMATS - ), + AudioParameter.VOICE: VoiceConstraint(voices=GOOGLE_VOICES), + AudioParameter.LANGUAGE: Choice(options=GOOGLE_SUPPORTED_LANGUAGES), + AudioParameter.OUTPUT_FORMAT: Choice(options=GOOGLE_SUPPORTED_FORMATS), }, ), ] diff --git a/src/celeste/modalities/audio/providers/google/parameters.py b/src/celeste/modalities/audio/providers/google/parameters.py new file mode 100644 index 00000000..2083ba19 --- /dev/null +++ b/src/celeste/modalities/audio/providers/google/parameters.py @@ -0,0 +1,72 @@ +"""Google parameter mappers for audio modality.""" + +from typing import ClassVar + +from celeste.mime_types import AudioMimeType +from celeste.parameters import ParameterMapper +from celeste.providers.google.cloud_tts.parameters import ( + AudioEncodingMapper as _AudioEncodingMapper, +) +from celeste.providers.google.cloud_tts.parameters import ( + LanguageMapper as _LanguageMapper, +) +from celeste.providers.google.cloud_tts.parameters import ( + VoiceMapper as _VoiceMapper, +) + +from ...parameters import AudioParameter + + +class VoiceMapper(_VoiceMapper): + """Map voice to Google Cloud TTS voice.name field.""" + + name = AudioParameter.VOICE + + +class LanguageMapper(_LanguageMapper): + """Map language to Google Cloud TTS voice.languageCode field.""" + + name = AudioParameter.LANGUAGE + locale_map: ClassVar[dict[str, str]] = { + "ar": "ar-EG", + "de": "de-DE", + "en": "en-US", + "es": "es-US", + "fr": "fr-FR", + "hi": "hi-IN", + "id": "id-ID", + "it": "it-IT", + "ja": "ja-JP", + "ko": "ko-KR", + "pt": "pt-BR", + "ru": "ru-RU", + "nl": "nl-NL", + "pl": "pl-PL", + "th": "th-TH", + "tr": "tr-TR", + "vi": "vi-VN", + "ro": "ro-RO", + "uk": "uk-UA", + "ta": "ta-IN", + } + + +class OutputFormatMapper(_AudioEncodingMapper): + """Map output_format to Google Cloud TTS audioConfig.audioEncoding field.""" + + name = AudioParameter.OUTPUT_FORMAT + encoding_map: ClassVar[dict[AudioMimeType, str]] = { + AudioMimeType.MP3: "MP3", + AudioMimeType.WAV: "LINEAR16", + AudioMimeType.OGG: "OGG_OPUS", + AudioMimeType.PCM: "PCM", + } + + +GOOGLE_PARAMETER_MAPPERS: list[ParameterMapper] = [ + VoiceMapper(), + LanguageMapper(), + OutputFormatMapper(), +] + +__all__ = ["GOOGLE_PARAMETER_MAPPERS"] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/voices.py b/src/celeste/modalities/audio/providers/google/voices.py similarity index 95% rename from packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/voices.py rename to src/celeste/modalities/audio/providers/google/voices.py index 4a3262b7..f8253c07 100644 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/voices.py +++ b/src/celeste/modalities/audio/providers/google/voices.py @@ -1,11 +1,12 @@ -"""Google TTS voice definitions for speech generation.""" +"""Google TTS voice definitions for audio modality.""" -from celeste import Provider -from celeste_speech_generation.languages import Language -from celeste_speech_generation.voices import Voice +from celeste.core import Provider + +from ...languages import Language +from ...voices import Voice # All supported languages for Google Gemini TTS (24 languages, auto-detected) -_SUPPORTED_LANGUAGES = [ +_SUPPORTED_LANGUAGES = { Language.ARABIC, Language.GERMAN, Language.ENGLISH, @@ -25,7 +26,7 @@ Language.ROMANIAN, Language.UKRAINIAN, Language.TAMIL, -] +} # Google Gemini TTS voices (30 voices, all support all languages) GOOGLE_VOICES = [ diff --git a/src/celeste/modalities/audio/providers/gradium/__init__.py b/src/celeste/modalities/audio/providers/gradium/__init__.py new file mode 100644 index 00000000..32ccc230 --- /dev/null +++ b/src/celeste/modalities/audio/providers/gradium/__init__.py @@ -0,0 +1,6 @@ +"""Gradium provider for audio modality.""" + +from .client import GradiumAudioClient +from .models import MODELS + +__all__ = ["MODELS", "GradiumAudioClient"] diff --git a/src/celeste/modalities/audio/providers/gradium/client.py b/src/celeste/modalities/audio/providers/gradium/client.py new file mode 100644 index 00000000..48586a65 --- /dev/null +++ b/src/celeste/modalities/audio/providers/gradium/client.py @@ -0,0 +1,144 @@ +"""Gradium audio client.""" + +from typing import Any, Unpack + +from celeste.artifacts import AudioArtifact +from celeste.parameters import ParameterMapper +from celeste.providers.gradium.text_to_speech import config +from celeste.providers.gradium.text_to_speech.client import ( + GradiumTextToSpeechClient as GradiumTextToSpeechMixin, +) +from celeste.providers.gradium.text_to_speech.streaming import ( + GradiumTextToSpeechStream as _GradiumTextToSpeechStream, +) + +from ...client import AudioClient +from ...io import ( + AudioChunk, + AudioFinishReason, + AudioInput, + AudioOutput, + AudioUsage, +) +from ...parameters import AudioParameters +from ...streaming import AudioStream +from .parameters import GRADIUM_PARAMETER_MAPPERS + + +class GradiumAudioStream(_GradiumTextToSpeechStream, AudioStream): + """Gradium streaming for audio modality.""" + + def _parse_chunk_usage(self, event_data: dict[str, Any]) -> AudioUsage | None: + """Parse and wrap usage from event.""" + usage = super()._parse_chunk_usage(event_data) + if usage: + return AudioUsage(**usage) + return None + + def _parse_chunk_finish_reason( + self, event_data: dict[str, Any] + ) -> AudioFinishReason | None: + """Parse and wrap finish reason from event.""" + finish_reason = super()._parse_chunk_finish_reason(event_data) + if finish_reason: + return AudioFinishReason(reason=finish_reason.reason) + return None + + def _parse_chunk(self, event_data: dict[str, Any]) -> AudioChunk | None: + """Parse binary audio chunk from stream event.""" + chunk_data = self._parse_chunk_content(event_data) + if not chunk_data: + usage = self._parse_chunk_usage(event_data) + finish_reason = self._parse_chunk_finish_reason(event_data) + if usage is None and finish_reason is None: + return None + # Chunk with usage/finish_reason only (no audio) + return AudioChunk( + content=b"", + finish_reason=finish_reason, + usage=usage, + metadata={"event_data": event_data}, + ) + + return AudioChunk( + content=chunk_data, + finish_reason=self._parse_chunk_finish_reason(event_data), + usage=self._parse_chunk_usage(event_data), + metadata={"event_data": event_data}, + ) + + def _aggregate_content(self, chunks: list[AudioChunk]) -> AudioArtifact: + """Aggregate audio content from chunks into AudioArtifact.""" + audio_bytes = b"".join(chunk.content for chunk in chunks if chunk.content) + # Get mime_type from output_format parameter via client + output_format = self._parameters.get("output_format") + client: GradiumAudioClient = self._client + mime_type = client._map_output_format_to_mime_type(output_format) + return AudioArtifact(data=audio_bytes, mime_type=mime_type) + + def _aggregate_event_data(self, chunks: list[AudioChunk]) -> list[dict[str, Any]]: + """Collect raw events (filtering happens in _build_stream_metadata).""" + events: list[dict[str, Any]] = [] + for chunk in chunks: + event_data = chunk.metadata.get("event_data") + if isinstance(event_data, dict): + events.append(event_data) + return events + + +class GradiumAudioClient(GradiumTextToSpeechMixin, AudioClient): + """Gradium audio client (TTS).""" + + @classmethod + def parameter_mappers(cls) -> list[ParameterMapper]: + return GRADIUM_PARAMETER_MAPPERS + + async def speak( + self, + text: str, + **parameters: Unpack[AudioParameters], + ) -> AudioOutput: + """Convert text to speech audio.""" + inputs = AudioInput(text=text) + return await self._predict( + inputs, + endpoint=config.GradiumTextToSpeechEndpoint.CREATE_SPEECH, + **parameters, + ) + + def _init_request(self, inputs: AudioInput) -> dict[str, Any]: + """Initialize request with text input.""" + return {"text": inputs.text} + + def _parse_usage(self, response_data: dict[str, Any]) -> AudioUsage: + """Parse usage from response.""" + usage = super()._parse_usage(response_data) + return AudioUsage(**usage) + + def _parse_content( + self, + response_data: dict[str, Any], + **parameters: Unpack[AudioParameters], + ) -> AudioArtifact: + """Extract audio bytes from response.""" + audio_bytes = response_data.get("audio_bytes") + if not audio_bytes: + msg = "No audio data in response" + raise ValueError(msg) + + output_format = parameters.get("output_format") + mime_type = self._map_output_format_to_mime_type(output_format) + + return AudioArtifact(data=audio_bytes, mime_type=mime_type) + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> AudioFinishReason: + """Parse finish reason from response.""" + finish_reason = super()._parse_finish_reason(response_data) + return AudioFinishReason(reason=finish_reason.reason) + + def _stream_class(self) -> type[AudioStream]: + """Return the Stream class for this provider.""" + return GradiumAudioStream + + +__all__ = ["GradiumAudioClient", "GradiumAudioStream"] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/models.py b/src/celeste/modalities/audio/providers/gradium/models.py similarity index 54% rename from packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/models.py rename to src/celeste/modalities/audio/providers/gradium/models.py index 8dd86ac1..cdab324e 100644 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/models.py +++ b/src/celeste/modalities/audio/providers/gradium/models.py @@ -1,10 +1,11 @@ -"""Gradium model definitions for speech generation.""" +"""Gradium models for audio modality.""" -from celeste import Model, Provider from celeste.constraints import Choice, Range -from celeste_speech_generation.constraints import VoiceConstraint -from celeste_speech_generation.parameters import SpeechGenerationParameter +from celeste.core import Modality, Operation, Provider +from celeste.models import Model +from ...constraints import VoiceConstraint +from ...parameters import AudioParameter from .voices import GRADIUM_VOICES MODELS: list[Model] = [ @@ -12,10 +13,11 @@ id="default", provider=Provider.GRADIUM, display_name="Gradium Default TTS", - streaming=False, + streaming=True, + operations={Modality.AUDIO: {Operation.SPEAK}}, parameter_constraints={ - SpeechGenerationParameter.VOICE: VoiceConstraint(voices=GRADIUM_VOICES), - SpeechGenerationParameter.OUTPUT_FORMAT: Choice( + AudioParameter.VOICE: VoiceConstraint(voices=GRADIUM_VOICES), + AudioParameter.OUTPUT_FORMAT: Choice( options=[ "wav", "pcm", @@ -26,7 +28,7 @@ "pcm_24000", ] ), - SpeechGenerationParameter.SPEED: Range(min=0.25, max=4.0), + AudioParameter.SPEED: Range(min=0.25, max=4.0), }, ), ] diff --git a/src/celeste/modalities/audio/providers/gradium/parameters.py b/src/celeste/modalities/audio/providers/gradium/parameters.py new file mode 100644 index 00000000..4d8ff942 --- /dev/null +++ b/src/celeste/modalities/audio/providers/gradium/parameters.py @@ -0,0 +1,31 @@ +"""Gradium parameter mappers for audio modality.""" + +from celeste.parameters import ParameterMapper +from celeste.providers.gradium.text_to_speech.parameters import ( + OutputFormatMapper as _OutputFormatMapper, +) +from celeste.providers.gradium.text_to_speech.parameters import ( + VoiceMapper as _VoiceMapper, +) + +from ...parameters import AudioParameter + + +class VoiceMapper(_VoiceMapper): + """Map voice to Gradium voice_id parameter.""" + + name = AudioParameter.VOICE + + +class OutputFormatMapper(_OutputFormatMapper): + """Map output_format to Gradium output_format parameter.""" + + name = AudioParameter.OUTPUT_FORMAT + + +GRADIUM_PARAMETER_MAPPERS: list[ParameterMapper] = [ + VoiceMapper(), + OutputFormatMapper(), +] + +__all__ = ["GRADIUM_PARAMETER_MAPPERS"] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/voices.py b/src/celeste/modalities/audio/providers/gradium/voices.py similarity index 93% rename from packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/voices.py rename to src/celeste/modalities/audio/providers/gradium/voices.py index de4a1d84..e53d2537 100644 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/voices.py +++ b/src/celeste/modalities/audio/providers/gradium/voices.py @@ -1,7 +1,8 @@ -"""Gradium voice definitions for speech generation.""" +"""Gradium voice definitions for audio modality.""" -from celeste import Provider -from celeste_speech_generation.voices import Voice +from celeste.core import Provider + +from ...voices import Voice # Gradium flagship voices # Full list at https://gradium.ai/api_dpocs.html diff --git a/src/celeste/modalities/audio/providers/openai/__init__.py b/src/celeste/modalities/audio/providers/openai/__init__.py new file mode 100644 index 00000000..e5c2e10a --- /dev/null +++ b/src/celeste/modalities/audio/providers/openai/__init__.py @@ -0,0 +1,6 @@ +"""OpenAI provider for audio modality.""" + +from .client import OpenAIAudioClient +from .models import MODELS + +__all__ = ["MODELS", "OpenAIAudioClient"] diff --git a/src/celeste/modalities/audio/providers/openai/client.py b/src/celeste/modalities/audio/providers/openai/client.py new file mode 100644 index 00000000..c0ba702f --- /dev/null +++ b/src/celeste/modalities/audio/providers/openai/client.py @@ -0,0 +1,67 @@ +"""OpenAI audio client.""" + +from typing import Any, Unpack + +from celeste.artifacts import AudioArtifact +from celeste.parameters import ParameterMapper +from celeste.providers.openai.audio import config +from celeste.providers.openai.audio.client import OpenAIAudioClient as OpenAIAudioMixin + +from ...client import AudioClient +from ...io import AudioFinishReason, AudioInput, AudioOutput, AudioUsage +from ...parameters import AudioParameters +from .parameters import OPENAI_PARAMETER_MAPPERS + + +class OpenAIAudioClient(OpenAIAudioMixin, AudioClient): + """OpenAI audio client (TTS).""" + + @classmethod + def parameter_mappers(cls) -> list[ParameterMapper]: + return OPENAI_PARAMETER_MAPPERS + + async def speak( + self, + text: str, + **parameters: Unpack[AudioParameters], + ) -> AudioOutput: + """Convert text to speech audio.""" + inputs = AudioInput(text=text) + return await self._predict( + inputs, + endpoint=config.OpenAIAudioEndpoint.CREATE_SPEECH, + **parameters, + ) + + def _init_request(self, inputs: AudioInput) -> dict[str, Any]: + """Initialize request with text input.""" + return {"input": inputs.text} + + def _parse_usage(self, response_data: dict[str, Any]) -> AudioUsage: + """Parse usage from response.""" + usage = super()._parse_usage(response_data) + return AudioUsage(**usage) + + def _parse_content( + self, + response_data: dict[str, Any], + **parameters: Unpack[AudioParameters], + ) -> AudioArtifact: + """Extract audio bytes from response.""" + audio_bytes = response_data.get("audio_bytes") + if not audio_bytes: + msg = "No audio data in response" + raise ValueError(msg) + + # Use mixin helper to determine MIME type from output_format + output_format = parameters.get("output_format") + mime_type = self._map_response_format_to_mime_type(output_format) + + return AudioArtifact(data=audio_bytes, mime_type=mime_type) + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> AudioFinishReason: + """OpenAI TTS doesn't provide finish reasons.""" + return AudioFinishReason(reason=None) + + +__all__ = ["OpenAIAudioClient"] diff --git a/src/celeste/modalities/audio/providers/openai/models.py b/src/celeste/modalities/audio/providers/openai/models.py new file mode 100644 index 00000000..f1ed04a4 --- /dev/null +++ b/src/celeste/modalities/audio/providers/openai/models.py @@ -0,0 +1,57 @@ +"""OpenAI models for audio modality.""" + +from celeste.constraints import Choice, Range +from celeste.core import Modality, Operation, Provider +from celeste.mime_types import AudioMimeType +from celeste.models import Model + +from ...constraints import VoiceConstraint +from ...parameters import AudioParameter +from .voices import GPT4O_MINI_TTS_VOICES, TTS1_HD_VOICES, TTS1_VOICES + +# Common response format options for all OpenAI TTS models +_RESPONSE_FORMAT_OPTIONS = [ + AudioMimeType.MP3, + AudioMimeType.OGG, # Maps to "opus" in OpenAI API + AudioMimeType.AAC, + AudioMimeType.FLAC, +] + +MODELS: list[Model] = [ + Model( + id="tts-1", + provider=Provider.OPENAI, + display_name="TTS-1", + streaming=False, + operations={Modality.AUDIO: {Operation.SPEAK}}, + parameter_constraints={ + AudioParameter.VOICE: VoiceConstraint(voices=TTS1_VOICES), + AudioParameter.SPEED: Range(min=0.25, max=4.0), + AudioParameter.OUTPUT_FORMAT: Choice(options=_RESPONSE_FORMAT_OPTIONS), + }, + ), + Model( + id="tts-1-hd", + provider=Provider.OPENAI, + display_name="TTS-1 HD", + streaming=False, + operations={Modality.AUDIO: {Operation.SPEAK}}, + parameter_constraints={ + AudioParameter.VOICE: VoiceConstraint(voices=TTS1_HD_VOICES), + AudioParameter.SPEED: Range(min=0.25, max=4.0), + AudioParameter.OUTPUT_FORMAT: Choice(options=_RESPONSE_FORMAT_OPTIONS), + }, + ), + Model( + id="gpt-4o-mini-tts", + provider=Provider.OPENAI, + display_name="GPT-4o Mini TTS", + streaming=False, + operations={Modality.AUDIO: {Operation.SPEAK}}, + parameter_constraints={ + AudioParameter.VOICE: VoiceConstraint(voices=GPT4O_MINI_TTS_VOICES), + AudioParameter.SPEED: Range(min=0.25, max=4.0), + AudioParameter.OUTPUT_FORMAT: Choice(options=_RESPONSE_FORMAT_OPTIONS), + }, + ), +] diff --git a/src/celeste/modalities/audio/providers/openai/parameters.py b/src/celeste/modalities/audio/providers/openai/parameters.py new file mode 100644 index 00000000..5754f960 --- /dev/null +++ b/src/celeste/modalities/audio/providers/openai/parameters.py @@ -0,0 +1,41 @@ +"""OpenAI parameter mappers for audio modality.""" + +from celeste.parameters import ParameterMapper +from celeste.providers.openai.audio.parameters import ( + ResponseFormatMapper as _ResponseFormatMapper, +) +from celeste.providers.openai.audio.parameters import ( + SpeedMapper as _SpeedMapper, +) +from celeste.providers.openai.audio.parameters import ( + VoiceMapper as _VoiceMapper, +) + +from ...parameters import AudioParameter + + +class VoiceMapper(_VoiceMapper): + """Map voice to OpenAI's voice parameter.""" + + name = AudioParameter.VOICE + + +class SpeedMapper(_SpeedMapper): + """Map speed to OpenAI's speed parameter.""" + + name = AudioParameter.SPEED + + +class OutputFormatMapper(_ResponseFormatMapper): + """Map output_format to OpenAI's response_format parameter.""" + + name = AudioParameter.OUTPUT_FORMAT + + +OPENAI_PARAMETER_MAPPERS: list[ParameterMapper] = [ + VoiceMapper(), + SpeedMapper(), + OutputFormatMapper(), +] + +__all__ = ["OPENAI_PARAMETER_MAPPERS"] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/voices.py b/src/celeste/modalities/audio/providers/openai/voices.py similarity index 95% rename from packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/voices.py rename to src/celeste/modalities/audio/providers/openai/voices.py index 67d40966..6ee9ae81 100644 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/voices.py +++ b/src/celeste/modalities/audio/providers/openai/voices.py @@ -1,7 +1,8 @@ -"""OpenAI voice definitions for speech generation.""" +"""OpenAI voice definitions for audio modality.""" -from celeste import Provider -from celeste_speech_generation.voices import Voice +from celeste.core import Provider + +from ...voices import Voice # Model-specific voice lists # tts-1 supports: alloy, ash, coral, echo, fable, nova, onyx, sage, shimmer (9 voices, NO ballad) diff --git a/src/celeste/modalities/audio/streaming.py b/src/celeste/modalities/audio/streaming.py new file mode 100644 index 00000000..7609c3f8 --- /dev/null +++ b/src/celeste/modalities/audio/streaming.py @@ -0,0 +1,89 @@ +"""Audio streaming primitives.""" + +from abc import abstractmethod +from collections.abc import AsyncIterator, Callable +from typing import Any, Unpack + +from celeste.artifacts import AudioArtifact +from celeste.client import ModalityClient +from celeste.streaming import Stream + +from .io import ( + AudioChunk, + AudioFinishReason, + AudioOutput, + AudioUsage, +) +from .parameters import AudioParameters + + +class AudioStream(Stream[AudioOutput, AudioParameters, AudioChunk]): + """Streaming for audio modality.""" + + def __init__( + self, + sse_iterator: AsyncIterator[dict[str, Any]], + transform_output: Callable[..., AudioArtifact], + client: ModalityClient, + **parameters: Unpack[AudioParameters], + ) -> None: + super().__init__(sse_iterator, **parameters) + self._transform_output = transform_output + self._client = client + + @abstractmethod + def _aggregate_content(self, chunks: list[AudioChunk]) -> AudioArtifact: + """Aggregate content from chunks into AudioArtifact.""" + ... + + def _aggregate_usage(self, chunks: list[AudioChunk]) -> AudioUsage: + """Aggregate usage across chunks.""" + for chunk in reversed(chunks): + if chunk.usage: + return chunk.usage + return AudioUsage() + + def _aggregate_finish_reason( + self, + chunks: list[AudioChunk], + ) -> AudioFinishReason | None: + """Aggregate finish reason across chunks.""" + for chunk in reversed(chunks): + if chunk.finish_reason: + return chunk.finish_reason + return None + + @abstractmethod + def _aggregate_event_data(self, chunks: list[AudioChunk]) -> list[dict[str, Any]]: + """Collect raw events.""" + ... + + def _build_stream_metadata( + self, raw_events: list[dict[str, Any]] + ) -> dict[str, Any]: + """Build streaming metadata.""" + return { + "model": self._client.model.id, + "provider": self._client.provider, + "modality": self._client.modality, + "raw_events": raw_events, + } + + def _parse_output( + self, + chunks: list[AudioChunk], + **parameters: Unpack[AudioParameters], + ) -> AudioOutput: + """Assemble chunks into final output.""" + raw_content = self._aggregate_content(chunks) + content: AudioArtifact = self._transform_output(raw_content, **parameters) + raw_events = self._aggregate_event_data(chunks) + return AudioOutput( + content=content, + usage=self._aggregate_usage(chunks), + finish_reason=self._aggregate_finish_reason(chunks), + metadata=self._build_stream_metadata(raw_events), + ) + + +__all__ = ["AudioStream"] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/voices.py b/src/celeste/modalities/audio/voices.py similarity index 65% rename from packages/capabilities/speech-generation/src/celeste_speech_generation/voices.py rename to src/celeste/modalities/audio/voices.py index 6d95205c..79b8b55b 100644 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/voices.py +++ b/src/celeste/modalities/audio/voices.py @@ -2,7 +2,9 @@ from pydantic import BaseModel, Field -from celeste import Provider +from celeste.core import Provider + +from .languages import Language class Voice(BaseModel): @@ -11,7 +13,7 @@ class Voice(BaseModel): id: str provider: Provider name: str - languages: set[str] = Field(default_factory=set) + languages: set[Language] = Field(default_factory=set) __all__ = ["Voice"] diff --git a/src/celeste/modalities/embeddings/__init__.py b/src/celeste/modalities/embeddings/__init__.py new file mode 100644 index 00000000..76d4fdd4 --- /dev/null +++ b/src/celeste/modalities/embeddings/__init__.py @@ -0,0 +1,22 @@ +"""Celeste Embeddings modality.""" + +from .client import EmbeddingsClient +from .io import ( + EmbeddingsChunk, + EmbeddingsFinishReason, + EmbeddingsInput, + EmbeddingsOutput, + EmbeddingsUsage, +) +from .parameters import EmbeddingsParameter, EmbeddingsParameters + +__all__ = [ + "EmbeddingsChunk", + "EmbeddingsClient", + "EmbeddingsFinishReason", + "EmbeddingsInput", + "EmbeddingsOutput", + "EmbeddingsParameter", + "EmbeddingsParameters", + "EmbeddingsUsage", +] diff --git a/src/celeste/modalities/embeddings/client.py b/src/celeste/modalities/embeddings/client.py new file mode 100644 index 00000000..01126cb6 --- /dev/null +++ b/src/celeste/modalities/embeddings/client.py @@ -0,0 +1,83 @@ +"""Embeddings modality client.""" + +from typing import Unpack + +from asgiref.sync import async_to_sync + +from celeste.client import ModalityClient +from celeste.core import Modality +from celeste.types import EmbeddingsContent + +from .io import EmbeddingsInput, EmbeddingsOutput +from .parameters import EmbeddingsParameters + + +class EmbeddingsClient( + ModalityClient[ + EmbeddingsInput, EmbeddingsOutput, EmbeddingsParameters, EmbeddingsContent + ] +): + """Base embeddings client. Providers implement operation methods.""" + + modality: Modality = Modality.EMBEDDINGS + + @classmethod + def _output_class(cls) -> type[EmbeddingsOutput]: + """Return the Output class for embeddings modality.""" + return EmbeddingsOutput + + async def embed( + self, + text: str | list[str], + **parameters: Unpack[EmbeddingsParameters], + ) -> EmbeddingsOutput: + """Generate embeddings from text. + + Args: + text: Text to embed. Single string or list of strings. + **parameters: Embedding parameters (e.g., dimensions). + + Returns: + EmbeddingsOutput with content as: + - list[float] if text was a string + - list[list[float]] if text was a list + """ + inputs = EmbeddingsInput(text=text) + output = await self._predict(inputs, **parameters) + + # If single text input, unwrap from batch format to single embedding + if ( + isinstance(text, str) + and isinstance(output.content, list) + and output.content + and isinstance(output.content[0], list) + ): + output.content = output.content[0] + + return output + + @property + def sync(self) -> "EmbeddingsSyncNamespace": + """Sync namespace for embeddings operations.""" + return EmbeddingsSyncNamespace(self) + + +class EmbeddingsSyncNamespace: + """Sync namespace for embeddings operations.""" + + def __init__(self, client: EmbeddingsClient) -> None: + self._client = client + + def embed( + self, + text: str | list[str], + **parameters: Unpack[EmbeddingsParameters], + ) -> EmbeddingsOutput: + """Blocking embeddings generation.""" + return async_to_sync(self._client.embed)(text, **parameters) + + +__all__ = [ + "EmbeddingsClient", + "EmbeddingsSyncNamespace", +] diff --git a/src/celeste/modalities/embeddings/io.py b/src/celeste/modalities/embeddings/io.py new file mode 100644 index 00000000..7ce6c711 --- /dev/null +++ b/src/celeste/modalities/embeddings/io.py @@ -0,0 +1,49 @@ +"""IO types for embeddings modality.""" + +from pydantic import Field + +from celeste.io import Chunk, FinishReason, Input, Output, Usage +from celeste.types import EmbeddingsContent + + +class EmbeddingsInput(Input): + """Input for embeddings operations.""" + + text: str | list[str] + + +class EmbeddingsFinishReason(FinishReason): + """Embeddings finish reason (for consistency).""" + + reason: str | None = None + message: str | None = None + + +class EmbeddingsUsage(Usage): + """Embeddings usage metrics.""" + + input_tokens: int | None = None + total_tokens: int | None = None + + +class EmbeddingsOutput(Output[EmbeddingsContent]): + """Output from embeddings operations.""" + + usage: EmbeddingsUsage = Field(default_factory=EmbeddingsUsage) + finish_reason: EmbeddingsFinishReason | None = None + + +class EmbeddingsChunk(Chunk[list[float]]): + """Chunk for embeddings streaming (for consistency, not used in practice).""" + + finish_reason: EmbeddingsFinishReason | None = None + usage: EmbeddingsUsage | None = None + + +__all__ = [ + "EmbeddingsChunk", + "EmbeddingsFinishReason", + "EmbeddingsInput", + "EmbeddingsOutput", + "EmbeddingsUsage", +] diff --git a/src/celeste/modalities/embeddings/models.py b/src/celeste/modalities/embeddings/models.py new file mode 100644 index 00000000..8299166e --- /dev/null +++ b/src/celeste/modalities/embeddings/models.py @@ -0,0 +1,9 @@ +"""Aggregated models for embeddings modality.""" + +from celeste.models import Model + +from .providers.google.models import MODELS as GOOGLE_MODELS + +MODELS: list[Model] = [ + *GOOGLE_MODELS, +] diff --git a/src/celeste/modalities/embeddings/parameters.py b/src/celeste/modalities/embeddings/parameters.py new file mode 100644 index 00000000..db954905 --- /dev/null +++ b/src/celeste/modalities/embeddings/parameters.py @@ -0,0 +1,23 @@ +"""Parameters for embeddings modality.""" + +from enum import StrEnum + +from celeste.parameters import Parameters + + +class EmbeddingsParameter(StrEnum): + """Parameter names for embeddings.""" + + DIMENSIONS = "dimensions" + + +class EmbeddingsParameters(Parameters): + """Parameters for embeddings operations.""" + + dimensions: int | None + + +__all__ = [ + "EmbeddingsParameter", + "EmbeddingsParameters", +] diff --git a/src/celeste/modalities/embeddings/providers/__init__.py b/src/celeste/modalities/embeddings/providers/__init__.py new file mode 100644 index 00000000..c79af425 --- /dev/null +++ b/src/celeste/modalities/embeddings/providers/__init__.py @@ -0,0 +1,10 @@ +"""Embeddings providers.""" + +from celeste.core import Provider + +from ..client import EmbeddingsClient +from .google import GoogleEmbeddingsClient + +PROVIDERS: dict[Provider, type[EmbeddingsClient]] = { + Provider.GOOGLE: GoogleEmbeddingsClient, +} diff --git a/src/celeste/modalities/embeddings/providers/google/__init__.py b/src/celeste/modalities/embeddings/providers/google/__init__.py new file mode 100644 index 00000000..20a0cf0b --- /dev/null +++ b/src/celeste/modalities/embeddings/providers/google/__init__.py @@ -0,0 +1,6 @@ +"""Google provider for embeddings modality.""" + +from .client import GoogleEmbeddingsClient +from .models import MODELS + +__all__ = ["MODELS", "GoogleEmbeddingsClient"] diff --git a/src/celeste/modalities/embeddings/providers/google/client.py b/src/celeste/modalities/embeddings/providers/google/client.py new file mode 100644 index 00000000..d09cc43f --- /dev/null +++ b/src/celeste/modalities/embeddings/providers/google/client.py @@ -0,0 +1,67 @@ +"""Google embeddings client.""" + +from typing import Any, Unpack + +from celeste.parameters import ParameterMapper +from celeste.providers.google.embeddings.client import ( + GoogleEmbeddingsClient as GoogleEmbeddingsMixin, +) +from celeste.types import EmbeddingsContent + +from ...client import EmbeddingsClient +from ...io import ( + EmbeddingsFinishReason, + EmbeddingsInput, + EmbeddingsUsage, +) +from ...parameters import EmbeddingsParameters +from .parameters import GOOGLE_PARAMETER_MAPPERS + + +class GoogleEmbeddingsClient(GoogleEmbeddingsMixin, EmbeddingsClient): + """Google embeddings client.""" + + @classmethod + def parameter_mappers(cls) -> list[ParameterMapper]: + """Return parameter mappers for Google embeddings.""" + return GOOGLE_PARAMETER_MAPPERS + + def _init_request(self, inputs: EmbeddingsInput) -> dict[str, Any]: + """Build Google embeddings request from inputs.""" + texts = inputs.text if isinstance(inputs.text, list) else [inputs.text] + + if len(texts) == 1: + return {"content": {"parts": [{"text": texts[0]}]}} + else: + return { + "requests": [ + { + "model": f"models/{self.model.id}", + "content": {"parts": [{"text": text}]}, + } + for text in texts + ] + } + + def _parse_usage(self, response_data: dict[str, Any]) -> EmbeddingsUsage: + """Parse usage from response (embeddings API doesn't provide usage).""" + usage = super()._parse_usage(response_data) + return EmbeddingsUsage(**usage) + + def _parse_content( + self, + response_data: dict[str, Any], + **parameters: Unpack[EmbeddingsParameters], + ) -> EmbeddingsContent: + """Parse embedding vectors from response.""" + return super()._parse_content(response_data) + + def _parse_finish_reason( + self, response_data: dict[str, Any] + ) -> EmbeddingsFinishReason: + """Parse finish reason (embeddings API doesn't provide finish reasons).""" + finish_reason = super()._parse_finish_reason(response_data) + return EmbeddingsFinishReason(reason=finish_reason.reason) + + +__all__ = ["GoogleEmbeddingsClient"] diff --git a/src/celeste/modalities/embeddings/providers/google/models.py b/src/celeste/modalities/embeddings/providers/google/models.py new file mode 100644 index 00000000..5441eac1 --- /dev/null +++ b/src/celeste/modalities/embeddings/providers/google/models.py @@ -0,0 +1,19 @@ +"""Google models for embeddings modality.""" + +from celeste.constraints import Choice +from celeste.core import Modality, Operation, Provider +from celeste.models import Model + +from ...parameters import EmbeddingsParameter + +MODELS: list[Model] = [ + Model( + id="gemini-embedding-001", + provider=Provider.GOOGLE, + display_name="Gemini Embedding 001", + operations={Modality.EMBEDDINGS: {Operation.EMBED}}, + parameter_constraints={ + EmbeddingsParameter.DIMENSIONS: Choice(options=[768, 1536, 3072]), + }, + ), +] diff --git a/src/celeste/modalities/embeddings/providers/google/parameters.py b/src/celeste/modalities/embeddings/providers/google/parameters.py new file mode 100644 index 00000000..639ae14c --- /dev/null +++ b/src/celeste/modalities/embeddings/providers/google/parameters.py @@ -0,0 +1,21 @@ +"""Google parameter mappers for embeddings.""" + +from celeste.parameters import ParameterMapper +from celeste.providers.google.embeddings.parameters import ( + OutputDimensionalityMapper as _OutputDimensionalityMapper, +) + +from ...parameters import EmbeddingsParameter + + +class DimensionsMapper(_OutputDimensionalityMapper): + """Map dimensions to Google's outputDimensionality parameter.""" + + name = EmbeddingsParameter.DIMENSIONS + + +GOOGLE_PARAMETER_MAPPERS: list[ParameterMapper] = [ + DimensionsMapper(), +] + +__all__ = ["GOOGLE_PARAMETER_MAPPERS"] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/py.typed b/src/celeste/modalities/embeddings/py.typed similarity index 100% rename from packages/capabilities/image-generation/src/celeste_image_generation/py.typed rename to src/celeste/modalities/embeddings/py.typed diff --git a/src/celeste/modalities/images/__init__.py b/src/celeste/modalities/images/__init__.py new file mode 100644 index 00000000..d6ffe04f --- /dev/null +++ b/src/celeste/modalities/images/__init__.py @@ -0,0 +1,22 @@ +"""Celeste Images modality.""" + +from .client import ImagesClient +from .io import ( + ImageChunk, + ImageFinishReason, + ImageInput, + ImageOutput, + ImageUsage, +) +from .parameters import ImageParameter, ImageParameters + +__all__ = [ + "ImageChunk", + "ImageFinishReason", + "ImageInput", + "ImageOutput", + "ImageParameter", + "ImageParameters", + "ImageUsage", + "ImagesClient", +] diff --git a/src/celeste/modalities/images/client.py b/src/celeste/modalities/images/client.py new file mode 100644 index 00000000..14cf72d2 --- /dev/null +++ b/src/celeste/modalities/images/client.py @@ -0,0 +1,169 @@ +"""Images modality client.""" + +from typing import Unpack + +from asgiref.sync import async_to_sync + +from celeste.artifacts import ImageArtifact +from celeste.client import ModalityClient +from celeste.core import Modality +from celeste.types import ImageContent + +from .io import ImageInput, ImageOutput +from .parameters import ImageParameters +from .streaming import ImagesStream + + +class ImagesClient( + ModalityClient[ImageInput, ImageOutput, ImageParameters, ImageContent] +): + """Base images client. Providers implement generate/edit methods.""" + + modality: Modality = Modality.IMAGES + + @classmethod + def _output_class(cls) -> type[ImageOutput]: + """Return the Output class for images modality.""" + return ImageOutput + + @property + def stream(self) -> "ImagesStreamNamespace": + """Streaming namespace for images operations.""" + return ImagesStreamNamespace(self) + + @property + def sync(self) -> "ImagesSyncNamespace": + """Sync namespace for images operations.""" + return ImagesSyncNamespace(self) + + +class ImagesStreamNamespace: + """Streaming namespace for images operations. + + Provides `client.stream.generate()` and `client.stream.edit()`. + """ + + def __init__(self, client: ImagesClient) -> None: + self._client = client + + def generate( + self, + prompt: str, + **parameters: Unpack[ImageParameters], + ) -> ImagesStream: + """Stream image generation.""" + inputs = ImageInput(prompt=prompt) + return self._client._stream( + inputs, + stream_class=self._client._stream_class(), + **parameters, + ) + + def edit( + self, + image: ImageArtifact, + prompt: str, + **parameters: Unpack[ImageParameters], + ) -> ImagesStream: + """Stream image editing.""" + inputs = ImageInput(prompt=prompt, image=image) + return self._client._stream( + inputs, + stream_class=self._client._stream_class(), + **parameters, + ) + + +class ImagesSyncNamespace: + """Sync namespace for images operations. + + Provides `client.sync.generate()` and `client.sync.edit()`. + """ + + def __init__(self, client: ImagesClient) -> None: + self._client = client + + def generate( + self, + prompt: str, + **parameters: Unpack[ImageParameters], + ) -> ImageOutput: + """Blocking image generation. + + Usage: + result = client.sync.generate("A sunset over mountains") + result.content.show() + """ + inputs = ImageInput(prompt=prompt) + return async_to_sync(self._client._predict)(inputs, **parameters) + + def edit( + self, + image: ImageArtifact, + prompt: str, + **parameters: Unpack[ImageParameters], + ) -> ImageOutput: + """Blocking image edit. + + Usage: + result = client.sync.edit(image, "Add a rainbow") + result.content.show() + """ + inputs = ImageInput(prompt=prompt, image=image) + return async_to_sync(self._client._predict)(inputs, **parameters) + + @property + def stream(self) -> "ImagesSyncStreamNamespace": + """Sync streaming namespace.""" + return ImagesSyncStreamNamespace(self._client) + + +class ImagesSyncStreamNamespace: + """Sync streaming namespace - returns Stream instance with sync iteration support.""" + + def __init__(self, client: ImagesClient) -> None: + self._client = client + + def generate( + self, + prompt: str, + **parameters: Unpack[ImageParameters], + ) -> ImagesStream: + """Sync streaming image generation. + + Returns Stream instance that supports both async and sync iteration. + + Usage: + stream = client.sync.stream.generate("A sunset over mountains") + for chunk in stream: # Sync iteration (bridges async internally) + print(chunk.content) + print(stream.output.usage) + """ + # Return same stream as async version - __iter__/__next__ handle sync iteration + return self._client.stream.generate(prompt, **parameters) + + def edit( + self, + image: ImageArtifact, + prompt: str, + **parameters: Unpack[ImageParameters], + ) -> ImagesStream: + """Sync streaming image editing. + + Returns Stream instance that supports both async and sync iteration. + + Usage: + stream = client.sync.stream.edit(image, "Add a rainbow") + for chunk in stream: + print(chunk.content) + print(stream.output.usage) + """ + return self._client.stream.edit(image, prompt, **parameters) + + +__all__ = [ + "ImagesClient", + "ImagesStreamNamespace", + "ImagesSyncNamespace", + "ImagesSyncStreamNamespace", +] diff --git a/src/celeste/modalities/images/io.py b/src/celeste/modalities/images/io.py new file mode 100644 index 00000000..be5058f6 --- /dev/null +++ b/src/celeste/modalities/images/io.py @@ -0,0 +1,57 @@ +"""IO types for images modality.""" + +from pydantic import Field + +from celeste.artifacts import ImageArtifact +from celeste.io import Chunk, FinishReason, Input, Output, Usage +from celeste.types import ImageContent + + +class ImageInput(Input): + """Input for images operations.""" + + prompt: str + image: ImageArtifact | None = None + + +class ImageFinishReason(FinishReason): + """Images finish reason.""" + + reason: str | None = None + message: str | None = None + + +class ImageUsage(Usage): + """Images usage metrics.""" + + total_tokens: int | None = None + input_tokens: int | None = None + output_tokens: int | None = None + reasoning_tokens: int | None = None + num_images: int | None = None + billed_units: float | None = None + input_mp: float | None = None + output_mp: float | None = None + + +class ImageOutput(Output[ImageContent]): + """Output from images operations.""" + + usage: ImageUsage = Field(default_factory=ImageUsage) + finish_reason: ImageFinishReason | None = None + + +class ImageChunk(Chunk[ImageArtifact]): + """Chunk for images streaming.""" + + finish_reason: ImageFinishReason | None = None + usage: ImageUsage | None = None + + +__all__ = [ + "ImageChunk", + "ImageFinishReason", + "ImageInput", + "ImageOutput", + "ImageUsage", +] diff --git a/src/celeste/modalities/images/models.py b/src/celeste/modalities/images/models.py new file mode 100644 index 00000000..e80db1f1 --- /dev/null +++ b/src/celeste/modalities/images/models.py @@ -0,0 +1,15 @@ +"""Aggregated models for images modality.""" + +from celeste.models import Model + +from .providers.bfl.models import MODELS as BFL_MODELS +from .providers.byteplus.models import MODELS as BYTEPLUS_MODELS +from .providers.google.models import MODELS as GOOGLE_MODELS +from .providers.openai.models import MODELS as OPENAI_MODELS + +MODELS: list[Model] = [ + *BFL_MODELS, + *BYTEPLUS_MODELS, + *GOOGLE_MODELS, + *OPENAI_MODELS, +] diff --git a/src/celeste/modalities/images/parameters.py b/src/celeste/modalities/images/parameters.py new file mode 100644 index 00000000..a6a1d012 --- /dev/null +++ b/src/celeste/modalities/images/parameters.py @@ -0,0 +1,48 @@ +"""Parameters for images modality.""" + +from enum import StrEnum + +from celeste.artifacts import ImageArtifact +from celeste.parameters import Parameters + + +class ImageParameter(StrEnum): + """Parameter names for images modality.""" + + ASPECT_RATIO = "aspect_ratio" + NUM_IMAGES = "num_images" + PARTIAL_IMAGES = "partial_images" + QUALITY = "quality" + WATERMARK = "watermark" + REFERENCE_IMAGES = "reference_images" + PROMPT_UPSAMPLING = "prompt_upsampling" + SEED = "seed" + SAFETY_TOLERANCE = "safety_tolerance" + OUTPUT_FORMAT = "output_format" + STEPS = "steps" + GUIDANCE = "guidance" + MASK = "mask" + + +class ImageParameters(Parameters): + """Parameters for images operations.""" + + aspect_ratio: str + num_images: int + partial_images: int + quality: str + watermark: bool + reference_images: list[ImageArtifact] + prompt_upsampling: bool + seed: int + safety_tolerance: int + output_format: str + steps: int + guidance: float + mask: ImageArtifact + + +__all__ = [ + "ImageParameter", + "ImageParameters", +] diff --git a/src/celeste/modalities/images/providers/__init__.py b/src/celeste/modalities/images/providers/__init__.py new file mode 100644 index 00000000..3e065734 --- /dev/null +++ b/src/celeste/modalities/images/providers/__init__.py @@ -0,0 +1,16 @@ +"""Images providers.""" + +from celeste.core import Provider + +from ..client import ImagesClient +from .bfl import BFLImagesClient +from .byteplus import BytePlusImagesClient +from .google import GoogleImagesClient +from .openai import OpenAIImagesClient + +PROVIDERS: dict[Provider, type[ImagesClient]] = { + Provider.BFL: BFLImagesClient, + Provider.BYTEPLUS: BytePlusImagesClient, + Provider.GOOGLE: GoogleImagesClient, + Provider.OPENAI: OpenAIImagesClient, +} diff --git a/src/celeste/modalities/images/providers/bfl/__init__.py b/src/celeste/modalities/images/providers/bfl/__init__.py new file mode 100644 index 00000000..f8f3c8b7 --- /dev/null +++ b/src/celeste/modalities/images/providers/bfl/__init__.py @@ -0,0 +1,6 @@ +"""BFL (Black Forest Labs) provider for images modality.""" + +from .client import BFLImagesClient +from .models import MODELS + +__all__ = ["MODELS", "BFLImagesClient"] diff --git a/src/celeste/modalities/images/providers/bfl/client.py b/src/celeste/modalities/images/providers/bfl/client.py new file mode 100644 index 00000000..4c7c1719 --- /dev/null +++ b/src/celeste/modalities/images/providers/bfl/client.py @@ -0,0 +1,89 @@ +"""BFL images client.""" + +from typing import Any, Unpack + +from celeste.artifacts import ImageArtifact +from celeste.parameters import ParameterMapper +from celeste.providers.bfl.images import config as bfl_config +from celeste.providers.bfl.images.client import BFLImagesClient as _BFLImagesClient +from celeste.providers.bfl.images.utils import encode_image + +from ...client import ImagesClient +from ...io import ImageFinishReason, ImageInput, ImageOutput, ImageUsage +from ...parameters import ImageParameters +from .parameters import BFL_PARAMETER_MAPPERS + + +class BFLImagesClient(_BFLImagesClient, ImagesClient): + """BFL images client (generate + edit).""" + + @classmethod + def parameter_mappers(cls) -> list[ParameterMapper]: + return BFL_PARAMETER_MAPPERS + + async def generate( + self, + prompt: str, + **parameters: Unpack[ImageParameters], + ) -> ImageOutput: + """Generate images from prompt.""" + inputs = ImageInput(prompt=prompt) + return await self._predict( + inputs, + endpoint=bfl_config.BFLImagesEndpoint.CREATE_IMAGE, + **parameters, + ) + + async def edit( + self, + image: ImageArtifact, + prompt: str, + **parameters: Unpack[ImageParameters], + ) -> ImageOutput: + """Edit an image with a prompt.""" + inputs = ImageInput(prompt=prompt, image=image) + return await self._predict( + inputs, + endpoint=bfl_config.BFLImagesEndpoint.CREATE_IMAGE, + **parameters, + ) + + def _init_request(self, inputs: ImageInput) -> dict[str, Any]: + """Build request with prompt and (optional) input image.""" + request: dict[str, Any] = {"prompt": inputs.prompt} + if inputs.image is not None: + request["input_image"] = encode_image(inputs.image) + return request + + def _parse_usage(self, response_data: dict[str, Any]) -> ImageUsage: + """Parse usage from response.""" + usage = super()._parse_usage(response_data) + return ImageUsage(**usage) + + def _parse_content( + self, + response_data: dict[str, Any], + **parameters: Unpack[ImageParameters], + ) -> ImageArtifact: + """Parse content from response.""" + result = super()._parse_content(response_data) + sample_url = result.get("sample") + + if not sample_url: + msg = f"No image URL in {self.provider} response" + raise ValueError(msg) + + return ImageArtifact(url=sample_url) + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> ImageFinishReason: + """Parse finish reason from response.""" + status = response_data.get("status") + if status == "Ready": + return ImageFinishReason(reason="COMPLETE") + elif status in ("Error", "Failed"): + error_msg = response_data.get("error", "Edit failed") + return ImageFinishReason(reason="ERROR", message=error_msg) + return ImageFinishReason(reason=None) + + +__all__ = ["BFLImagesClient"] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/bfl/models.py b/src/celeste/modalities/images/providers/bfl/models.py similarity index 61% rename from packages/capabilities/image-generation/src/celeste_image_generation/providers/bfl/models.py rename to src/celeste/modalities/images/providers/bfl/models.py index ba0cbdde..495e0188 100644 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/bfl/models.py +++ b/src/celeste/modalities/images/providers/bfl/models.py @@ -1,17 +1,19 @@ -"""BFL (Black Forest Labs) models for FLUX.2 image generation.""" +"""BFL (Black Forest Labs) models for images modality.""" -from celeste import Model, Provider -from celeste.constraints import Bool, Choice, Int, Range -from celeste_image_generation.constraints import Dimensions -from celeste_image_generation.parameters import ImageGenerationParameter +from celeste.constraints import Choice, Dimensions, ImagesConstraint, Int, Range +from celeste.core import Modality, Operation, Provider +from celeste.models import Model + +from ...parameters import ImageParameter MODELS: list[Model] = [ Model( id="flux-2-max", provider=Provider.BFL, display_name="FLUX.2 [max]", + operations={Modality.IMAGES: {Operation.GENERATE, Operation.EDIT}}, parameter_constraints={ - ImageGenerationParameter.ASPECT_RATIO: Dimensions( + ImageParameter.ASPECT_RATIO: Dimensions( min_pixels=64 * 64, # 4,096 max_pixels=2048 * 2048, # 4,194,304 (4MP) min_aspect_ratio=9 / 21, # ~0.429 @@ -29,17 +31,19 @@ ), # Note: flux-2-max always upsamples prompts (no prompt_upsampling parameter) # Includes grounding search for real-time information integration - ImageGenerationParameter.SEED: Int(), - ImageGenerationParameter.SAFETY_TOLERANCE: Range(min=0, max=5), - ImageGenerationParameter.OUTPUT_FORMAT: Choice(options=["jpeg", "png"]), + ImageParameter.REFERENCE_IMAGES: ImagesConstraint(max_count=7), + ImageParameter.SEED: Int(), + ImageParameter.SAFETY_TOLERANCE: Range(min=0, max=5), + ImageParameter.OUTPUT_FORMAT: Choice(options=["jpeg", "png"]), }, ), Model( id="flux-2-pro", provider=Provider.BFL, display_name="FLUX.2 [pro]", + operations={Modality.IMAGES: {Operation.GENERATE, Operation.EDIT}}, parameter_constraints={ - ImageGenerationParameter.ASPECT_RATIO: Dimensions( + ImageParameter.ASPECT_RATIO: Dimensions( min_pixels=64 * 64, # 4,096 max_pixels=2048 * 2048, # 4,194,304 (4MP) min_aspect_ratio=9 / 21, # ~0.429 @@ -55,18 +59,19 @@ "Portrait 9:21": "832x1920", }, ), - # Note: flux-2-pro always upsamples prompts (no prompt_upsampling parameter) - ImageGenerationParameter.SEED: Int(), - ImageGenerationParameter.SAFETY_TOLERANCE: Range(min=0, max=5), - ImageGenerationParameter.OUTPUT_FORMAT: Choice(options=["jpeg", "png"]), + ImageParameter.REFERENCE_IMAGES: ImagesConstraint(max_count=7), + ImageParameter.SEED: Int(), + ImageParameter.SAFETY_TOLERANCE: Range(min=0, max=5), + ImageParameter.OUTPUT_FORMAT: Choice(options=["jpeg", "png"]), }, ), Model( id="flux-2-flex", provider=Provider.BFL, display_name="FLUX.2 [flex]", + operations={Modality.IMAGES: {Operation.GENERATE, Operation.EDIT}}, parameter_constraints={ - ImageGenerationParameter.ASPECT_RATIO: Dimensions( + ImageParameter.ASPECT_RATIO: Dimensions( min_pixels=64 * 64, # 4,096 max_pixels=2048 * 2048, # 4,194,304 (4MP) min_aspect_ratio=9 / 21, # ~0.429 @@ -82,12 +87,14 @@ "Portrait 9:21": "832x1920", }, ), - ImageGenerationParameter.PROMPT_UPSAMPLING: Bool(), - ImageGenerationParameter.SEED: Int(), - ImageGenerationParameter.SAFETY_TOLERANCE: Range(min=0, max=5), - ImageGenerationParameter.OUTPUT_FORMAT: Choice(options=["jpeg", "png"]), - ImageGenerationParameter.STEPS: Range(min=1, max=50), - ImageGenerationParameter.GUIDANCE: Range(min=1.5, max=10.0), + ImageParameter.REFERENCE_IMAGES: ImagesConstraint(max_count=9), + ImageParameter.SEED: Int(), + ImageParameter.SAFETY_TOLERANCE: Range(min=0, max=5), + ImageParameter.OUTPUT_FORMAT: Choice(options=["jpeg", "png"]), + ImageParameter.STEPS: Range(min=1, max=50), + ImageParameter.GUIDANCE: Range(min=1.5, max=10.0), }, ), ] + +__all__ = ["MODELS"] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/bfl/parameters.py b/src/celeste/modalities/images/providers/bfl/parameters.py similarity index 59% rename from packages/capabilities/image-generation/src/celeste_image_generation/providers/bfl/parameters.py rename to src/celeste/modalities/images/providers/bfl/parameters.py index 7a3a575f..f5c64c5e 100644 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/bfl/parameters.py +++ b/src/celeste/modalities/images/providers/bfl/parameters.py @@ -1,39 +1,36 @@ -"""BFL Images parameter mappers for image generation. - -AspectRatioMapper is defined locally (handles "WxH" → width/height transformation). -Other mappers subclass from provider and add capability-specific `name` attribute. -""" +"""BFL parameter mappers for images modality.""" from typing import Any -from celeste_bfl.images.parameters import ( +from celeste.models import Model +from celeste.parameters import ParameterMapper +from celeste.providers.bfl.images.parameters import ( GuidanceMapper as _GuidanceMapper, ) -from celeste_bfl.images.parameters import ( +from celeste.providers.bfl.images.parameters import ( HeightMapper as _HeightMapper, ) -from celeste_bfl.images.parameters import ( +from celeste.providers.bfl.images.parameters import ( OutputFormatMapper as _OutputFormatMapper, ) -from celeste_bfl.images.parameters import ( +from celeste.providers.bfl.images.parameters import ( PromptUpsamplingMapper as _PromptUpsamplingMapper, ) -from celeste_bfl.images.parameters import ( +from celeste.providers.bfl.images.parameters import ( SafetyToleranceMapper as _SafetyToleranceMapper, ) -from celeste_bfl.images.parameters import ( +from celeste.providers.bfl.images.parameters import ( SeedMapper as _SeedMapper, ) -from celeste_bfl.images.parameters import ( +from celeste.providers.bfl.images.parameters import ( StepsMapper as _StepsMapper, ) -from celeste_bfl.images.parameters import ( +from celeste.providers.bfl.images.parameters import ( WidthMapper as _WidthMapper, ) +from celeste.providers.bfl.images.utils import add_reference_images -from celeste.models import Model -from celeste.parameters import ParameterMapper -from celeste_image_generation.parameters import ImageGenerationParameter +from ...parameters import ImageParameter class AspectRatioMapper(ParameterMapper): @@ -43,7 +40,7 @@ class AspectRatioMapper(ParameterMapper): Delegates to provider's WidthMapper and HeightMapper for the actual mapping. """ - name = ImageGenerationParameter.ASPECT_RATIO + name = ImageParameter.ASPECT_RATIO def map( self, @@ -71,27 +68,44 @@ def map( class PromptUpsamplingMapper(_PromptUpsamplingMapper): - name = ImageGenerationParameter.PROMPT_UPSAMPLING + name = ImageParameter.PROMPT_UPSAMPLING class SeedMapper(_SeedMapper): - name = ImageGenerationParameter.SEED + name = ImageParameter.SEED class SafetyToleranceMapper(_SafetyToleranceMapper): - name = ImageGenerationParameter.SAFETY_TOLERANCE + name = ImageParameter.SAFETY_TOLERANCE class OutputFormatMapper(_OutputFormatMapper): - name = ImageGenerationParameter.OUTPUT_FORMAT + name = ImageParameter.OUTPUT_FORMAT class StepsMapper(_StepsMapper): - name = ImageGenerationParameter.STEPS + name = ImageParameter.STEPS class GuidanceMapper(_GuidanceMapper): - name = ImageGenerationParameter.GUIDANCE + name = ImageParameter.GUIDANCE + + +class ReferenceImagesMapper(ParameterMapper): + name = ImageParameter.REFERENCE_IMAGES + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform reference_images into provider request fields.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + return add_reference_images(request, validated_value) BFL_PARAMETER_MAPPERS: list[ParameterMapper] = [ @@ -102,6 +116,7 @@ class GuidanceMapper(_GuidanceMapper): OutputFormatMapper(), StepsMapper(), GuidanceMapper(), + ReferenceImagesMapper(), ] __all__ = ["BFL_PARAMETER_MAPPERS"] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/README.md b/src/celeste/modalities/images/providers/byteplus/README.md similarity index 100% rename from packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/README.md rename to src/celeste/modalities/images/providers/byteplus/README.md diff --git a/src/celeste/modalities/images/providers/byteplus/__init__.py b/src/celeste/modalities/images/providers/byteplus/__init__.py new file mode 100644 index 00000000..04706f44 --- /dev/null +++ b/src/celeste/modalities/images/providers/byteplus/__init__.py @@ -0,0 +1,6 @@ +"""BytePlus provider for images modality.""" + +from .client import BytePlusImagesClient +from .models import MODELS + +__all__ = ["MODELS", "BytePlusImagesClient"] diff --git a/src/celeste/modalities/images/providers/byteplus/client.py b/src/celeste/modalities/images/providers/byteplus/client.py new file mode 100644 index 00000000..efbd07ef --- /dev/null +++ b/src/celeste/modalities/images/providers/byteplus/client.py @@ -0,0 +1,207 @@ +"""BytePlus images client.""" + +import base64 +from typing import Any, Unpack + +from celeste.artifacts import ImageArtifact +from celeste.exceptions import ConstraintViolationError, ValidationError +from celeste.mime_types import ImageMimeType +from celeste.parameters import ParameterMapper +from celeste.providers.byteplus.images import config +from celeste.providers.byteplus.images.client import ( + BytePlusImagesClient as BytePlusImagesMixin, +) +from celeste.providers.byteplus.images.streaming import ( + BytePlusImagesStream as _BytePlusImagesStream, +) + +from ...client import ImagesClient +from ...io import ( + ImageChunk, + ImageFinishReason, + ImageInput, + ImageOutput, + ImageUsage, +) +from ...parameters import ImageParameters +from ...streaming import ImagesStream +from .parameters import BYTEPLUS_PARAMETER_MAPPERS + + +class BytePlusImagesStream(_BytePlusImagesStream, ImagesStream): + """BytePlus streaming for images modality.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._completed_usage: ImageUsage | None = None + self._completed_event_data: dict[str, Any] | None = None + + def _parse_chunk_usage(self, event_data: dict[str, Any]) -> ImageUsage | None: + """Parse and wrap usage from SSE event.""" + usage = super()._parse_chunk_usage(event_data) + if usage: + return ImageUsage(**usage) + return None + + def _parse_chunk_finish_reason( + self, event_data: dict[str, Any] + ) -> ImageFinishReason | None: + """Parse and wrap finish reason from SSE event.""" + finish_reason = super()._parse_chunk_finish_reason(event_data) + if finish_reason: + return ImageFinishReason(reason=finish_reason.reason) + return None + + def _parse_chunk(self, event_data: dict[str, Any]) -> ImageChunk | None: + """Parse one SSE event into a typed chunk.""" + # Handle error events (partial_failed) + if self._is_error_event(event_data): + error = self._parse_chunk_error(event_data) + return ImageChunk( + content=ImageArtifact(data=b""), + finish_reason=None, + usage=None, + metadata={"event_data": event_data, "error": error}, + ) + + # Handle completed event (usage only) + usage = self._parse_chunk_usage(event_data) + if usage is not None: + self._completed_usage = usage + self._completed_event_data = event_data + return None + + # Handle partial succeeded (image content) + content = self._parse_chunk_content(event_data) + if not content: + return None + + content_type = self._parse_chunk_content_type(event_data) + if content_type == "url": + artifact = ImageArtifact(url=content, mime_type=ImageMimeType.PNG) + else: # b64_json + image_data = base64.b64decode(content) + artifact = ImageArtifact(data=image_data) + + return ImageChunk( + content=artifact, + finish_reason=self._parse_chunk_finish_reason(event_data), + usage=None, + metadata={"event_data": event_data}, + ) + + def _aggregate_content(self, chunks: list[ImageChunk]) -> ImageArtifact: + """Aggregate image content from chunks.""" + return chunks[-1].content + + def _aggregate_usage(self, chunks: list[ImageChunk]) -> ImageUsage: + """Override: Use usage from completed event.""" + if self._completed_usage is not None: + return self._completed_usage + return super()._aggregate_usage(chunks) + + def _aggregate_event_data(self, chunks: list[ImageChunk]) -> list[dict[str, Any]]: + """Collect metadata events (skip content-only events).""" + events: list[dict[str, Any]] = [] + if self._completed_event_data is not None: + events.append(self._completed_event_data) + for chunk in chunks: + event_data = chunk.metadata.get("event_data") + if isinstance(event_data, dict): + events.append(event_data) + return events + + +class BytePlusImagesClient(BytePlusImagesMixin, ImagesClient): + """BytePlus images client (generate + streaming).""" + + @classmethod + def parameter_mappers(cls) -> list[ParameterMapper]: + return BYTEPLUS_PARAMETER_MAPPERS + + async def generate( + self, + prompt: str, + **parameters: Unpack[ImageParameters], + ) -> ImageOutput: + """Generate images from prompt.""" + inputs = ImageInput(prompt=prompt) + return await self._predict( + inputs, + endpoint=config.BytePlusImagesEndpoint.CREATE_IMAGE, + **parameters, + ) + + def _init_request(self, inputs: ImageInput) -> dict[str, Any]: + """Initialize request from BytePlus API structure.""" + return { + "prompt": inputs.prompt, + "response_format": "url", + } + + def _parse_usage(self, response_data: dict[str, Any]) -> ImageUsage: + """Parse usage from response.""" + usage = super()._parse_usage(response_data) + return ImageUsage(**usage) + + def _parse_content( + self, + response_data: dict[str, Any], + **parameters: Unpack[ImageParameters], + ) -> ImageArtifact: + """Parse content from response.""" + content = super()._parse_content(response_data) + if not content: + msg = "No image content found in BytePlus response" + raise ValidationError(msg) + + image_data = content[0] + if image_data.get("url"): + return ImageArtifact( + url=image_data["url"], + mime_type=ImageMimeType.PNG, + ) + if image_data.get("b64_json"): + image_bytes = base64.b64decode(image_data["b64_json"]) + return ImageArtifact( + data=image_bytes, + mime_type=ImageMimeType.PNG, + ) + + msg = "No image URL or base64 data in BytePlus response" + raise ValidationError(msg) + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> ImageFinishReason: + """Parse finish reason from response.""" + finish_reason = super()._parse_finish_reason(response_data) + return ImageFinishReason(reason=finish_reason.reason) + + async def _make_request( + self, + request_body: dict[str, Any], + *, + endpoint: str | None = None, + **parameters: Unpack[ImageParameters], + ) -> dict[str, Any]: + """Make HTTP request with parameter validation.""" + # Validate mutually exclusive parameters + if parameters.get("aspect_ratio") and parameters.get("quality"): + msg = ( + "Cannot use both 'aspect_ratio' and 'quality' parameters. " + "BytePlus's 'size' field supports two methods that cannot be combined:\n" + " • quality: Resolution class ('1K', '2K', '4K')\n" + " • aspect_ratio: Exact dimensions (e.g., '2048x2048', '3840x2160')\n" + "Use one or the other, not both." + ) + raise ConstraintViolationError(msg) + + return await super()._make_request( + request_body, endpoint=endpoint, **parameters + ) + + def _stream_class(self) -> type[ImagesStream]: + """Return the Stream class for this provider.""" + return BytePlusImagesStream + + +__all__ = ["BytePlusImagesClient", "BytePlusImagesStream"] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/models.py b/src/celeste/modalities/images/providers/byteplus/models.py similarity index 68% rename from packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/models.py rename to src/celeste/modalities/images/providers/byteplus/models.py index 71e273c0..fe220cdb 100644 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/models.py +++ b/src/celeste/modalities/images/providers/byteplus/models.py @@ -1,17 +1,20 @@ -"""BytePlus models for image generation.""" +"""BytePlus models for images modality.""" -from celeste import Model, Provider -from celeste.constraints import Bool, Choice -from celeste_image_generation.constraints import Dimensions -from celeste_image_generation.parameters import ImageGenerationParameter +from celeste.constraints import Bool, Choice, Dimensions +from celeste.core import Modality, Operation, Provider +from celeste.models import Model + +from ...parameters import ImageParameter MODELS: list[Model] = [ Model( id="seedream-4-0-250828", provider=Provider.BYTEPLUS, display_name="Seedream 4.0", + operations={Modality.IMAGES: {Operation.GENERATE}}, + streaming=True, parameter_constraints={ - ImageGenerationParameter.ASPECT_RATIO: Dimensions( + ImageParameter.ASPECT_RATIO: Dimensions( min_pixels=1280 * 720, # 921,600 max_pixels=4096 * 4096, # 16,777,216 min_aspect_ratio=1 / 16, # 0.0625 @@ -27,16 +30,18 @@ "Ultra-wide 21:9": "3024x1296", }, ), - ImageGenerationParameter.QUALITY: Choice(options=["1K", "2K", "4K"]), - ImageGenerationParameter.WATERMARK: Bool(), + ImageParameter.QUALITY: Choice(options=["1K", "2K", "4K"]), + ImageParameter.WATERMARK: Bool(), }, ), Model( id="seedream-4-5-251128", provider=Provider.BYTEPLUS, display_name="Seedream 4.5", + operations={Modality.IMAGES: {Operation.GENERATE}}, + streaming=True, parameter_constraints={ - ImageGenerationParameter.ASPECT_RATIO: Dimensions( + ImageParameter.ASPECT_RATIO: Dimensions( min_pixels=2560 * 1440, # 3,686,400 max_pixels=4096 * 4096, # 16,777,216 min_aspect_ratio=1 / 16, # 0.0625 @@ -52,8 +57,8 @@ "Ultra-wide 21:9": "3024x1296", }, ), - ImageGenerationParameter.QUALITY: Choice(options=["2K", "4K"]), - ImageGenerationParameter.WATERMARK: Bool(), + ImageParameter.QUALITY: Choice(options=["2K", "4K"]), + ImageParameter.WATERMARK: Bool(), }, ), ] diff --git a/src/celeste/modalities/images/providers/byteplus/parameters.py b/src/celeste/modalities/images/providers/byteplus/parameters.py new file mode 100644 index 00000000..9942fc78 --- /dev/null +++ b/src/celeste/modalities/images/providers/byteplus/parameters.py @@ -0,0 +1,34 @@ +"""BytePlus parameter mappers for images modality.""" + +from celeste.parameters import ParameterMapper +from celeste.providers.byteplus.images.parameters import ( + SizeMapper as _SizeMapper, +) +from celeste.providers.byteplus.images.parameters import ( + WatermarkMapper as _WatermarkMapper, +) + +from ...parameters import ImageParameter + + +class AspectRatioMapper(_SizeMapper): + name = ImageParameter.ASPECT_RATIO + + +class QualityMapper(_SizeMapper): + """Map quality to BytePlus size field with conflict resolution.""" + + name = ImageParameter.QUALITY + + +class WatermarkMapper(_WatermarkMapper): + name = ImageParameter.WATERMARK + + +BYTEPLUS_PARAMETER_MAPPERS: list[ParameterMapper] = [ + AspectRatioMapper(), + QualityMapper(), + WatermarkMapper(), +] + +__all__ = ["BYTEPLUS_PARAMETER_MAPPERS"] diff --git a/src/celeste/modalities/images/providers/google/__init__.py b/src/celeste/modalities/images/providers/google/__init__.py new file mode 100644 index 00000000..a77f9721 --- /dev/null +++ b/src/celeste/modalities/images/providers/google/__init__.py @@ -0,0 +1,6 @@ +"""Google provider for images modality.""" + +from .client import GoogleImagesClient +from .models import MODELS + +__all__ = ["MODELS", "GoogleImagesClient"] diff --git a/src/celeste/modalities/images/providers/google/client.py b/src/celeste/modalities/images/providers/google/client.py new file mode 100644 index 00000000..cf4ed5b0 --- /dev/null +++ b/src/celeste/modalities/images/providers/google/client.py @@ -0,0 +1,111 @@ +"""Google images client.""" + +from typing import Any, Unpack + +from celeste.artifacts import ImageArtifact +from celeste.parameters import ParameterMapper +from celeste.types import ImageContent + +from ...client import ImagesClient +from ...io import ImageFinishReason, ImageInput, ImageOutput, ImageUsage +from ...parameters import ImageParameters +from .gemini import GeminiImagesClient +from .imagen import ImagenImagesClient +from .models import GEMINI_MODELS, IMAGEN_MODELS +from .parameters import GEMINI_PARAMETER_MAPPERS, IMAGEN_PARAMETER_MAPPERS + +# Model ID → Strategy class mapping +GOOGLE_MODEL_MAP: dict[str, type[GeminiImagesClient] | type[ImagenImagesClient]] = { + **{m.id: ImagenImagesClient for m in IMAGEN_MODELS}, + **{m.id: GeminiImagesClient for m in GEMINI_MODELS}, +} + + +class GoogleImagesClient(ImagesClient): + """Google images client (dispatches between Imagen and Gemini backends).""" + + _strategy: GeminiImagesClient | ImagenImagesClient | None = None + + def model_post_init(self, __context: object) -> None: + """Initialize strategy based on model id.""" + super().model_post_init(__context) + + StrategyClass = GOOGLE_MODEL_MAP.get(self.model.id) + if StrategyClass is None: + msg = f"Unknown Google images model: {self.model.id}" + raise ValueError(msg) + + strategy = StrategyClass( + modality=self.modality, + model=self.model, + provider=self.provider, + auth=self.auth, + ) + object.__setattr__(self, "_strategy", strategy) + + @classmethod + def parameter_mappers(cls) -> list[ParameterMapper]: + return [*GEMINI_PARAMETER_MAPPERS, *IMAGEN_PARAMETER_MAPPERS] + + async def generate( + self, + prompt: str, + **parameters: Unpack[ImageParameters], + ) -> ImageOutput: + """Generate images from prompt.""" + inputs = ImageInput(prompt=prompt) + return await self._predict(inputs, **parameters) + + async def edit( + self, + image: ImageArtifact, + prompt: str, + **parameters: Unpack[ImageParameters], + ) -> ImageOutput: + # Only Gemini supports edit in Google provider + if not isinstance(self._strategy, GeminiImagesClient): + msg = f"Model '{self.model.id}' does not support image editing" + raise ValueError(msg) + + inputs = ImageInput(prompt=prompt, image=image) + return await self._predict(inputs, **parameters) + + def _init_request(self, inputs: ImageInput) -> dict[str, Any]: + """Delegate to strategy's _init_request.""" + return self._strategy._init_request(inputs) # type: ignore[union-attr] + + def _build_request( + self, + inputs: ImageInput, + **parameters: Unpack[ImageParameters], + ) -> dict[str, Any]: + return self._strategy._build_request(inputs, **parameters) # type: ignore[union-attr] + + def _parse_usage(self, response_data: dict[str, Any]) -> ImageUsage: + return self._strategy._parse_usage(response_data) # type: ignore[union-attr] + + def _parse_content( + self, + response_data: dict[str, Any], + **parameters: Unpack[ImageParameters], + ) -> ImageContent: + return self._strategy._parse_content(response_data, **parameters) # type: ignore[union-attr] + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> ImageFinishReason: + return self._strategy._parse_finish_reason(response_data) # type: ignore[union-attr] + + async def _make_request( + self, + request_body: dict[str, Any], + *, + endpoint: str | None = None, + **parameters: Unpack[ImageParameters], + ) -> dict[str, Any]: + return await self._strategy._make_request( # type: ignore[union-attr] + request_body, + endpoint=endpoint, + **parameters, + ) + + +__all__ = ["GoogleImagesClient"] diff --git a/src/celeste/modalities/images/providers/google/gemini.py b/src/celeste/modalities/images/providers/google/gemini.py new file mode 100644 index 00000000..898fcb14 --- /dev/null +++ b/src/celeste/modalities/images/providers/google/gemini.py @@ -0,0 +1,133 @@ +"""Gemini client for Google images modality.""" + +import base64 +from typing import Any, Unpack + +from celeste.artifacts import ImageArtifact +from celeste.mime_types import ImageMimeType +from celeste.parameters import ParameterMapper +from celeste.providers.google.generate_content import config as google_config +from celeste.providers.google.generate_content.client import GoogleGenerateContentClient +from celeste.types import ImageContent + +from ...client import ImagesClient +from ...io import ImageFinishReason, ImageInput, ImageOutput, ImageUsage +from ...parameters import ImageParameters +from .parameters import GEMINI_PARAMETER_MAPPERS + + +def _build_image_part(image: ImageArtifact) -> dict[str, Any]: + """Build a Gemini image part from an ImageArtifact (snake_case, provider-style).""" + if image.url: + return {"file_data": {"file_uri": image.url}} + + if image.data is not None: + image_bytes = image.data + elif image.path: + with open(image.path, "rb") as f: + image_bytes = f.read() + else: + msg = "ImageArtifact must have url, data, or path" + raise ValueError(msg) + + base64_data = base64.b64encode(image_bytes).decode("utf-8") + return { + "inline_data": { + "mime_type": image.mime_type, + "data": base64_data, + } + } + + +class GeminiImagesClient(GoogleGenerateContentClient, ImagesClient): + """Google Gemini client for images modality (generate + edit).""" + + @classmethod + def parameter_mappers(cls) -> list[ParameterMapper]: + return GEMINI_PARAMETER_MAPPERS + + async def generate( + self, + prompt: str, + **parameters: Unpack[ImageParameters], + ) -> ImageOutput: + inputs = ImageInput(prompt=prompt) + return await self._predict( + inputs, + endpoint=google_config.GoogleGenerateContentEndpoint.GENERATE_CONTENT, + **parameters, + ) + + async def edit( + self, + image: ImageArtifact, + prompt: str, + **parameters: Unpack[ImageParameters], + ) -> ImageOutput: + inputs = ImageInput(prompt=prompt, image=image) + return await self._predict( + inputs, + endpoint=google_config.GoogleGenerateContentEndpoint.GENERATE_CONTENT, + **parameters, + ) + + def _init_request(self, inputs: ImageInput) -> dict[str, Any]: + """Initialize request for Gemini image generation/edit.""" + parts: list[dict[str, Any]] = [] + + # Edit uses an input image (generation omits it) + if inputs.image is not None: + parts.append(_build_image_part(inputs.image)) + + parts.append({"text": inputs.prompt}) + + return { + "contents": [{"parts": parts}], + "generationConfig": { + "responseModalities": ["Image"], + "imageConfig": {}, + }, + } + + def _parse_usage(self, response_data: dict[str, Any]) -> ImageUsage: + """Parse usage from response.""" + usage = super()._parse_usage(response_data) + candidates = response_data.get("candidates", []) + return ImageUsage(**usage, num_images=len(candidates)) + + def _parse_content( + self, + response_data: dict[str, Any], + **parameters: Unpack[ImageParameters], + ) -> ImageContent: + """Parse image artifacts from Gemini candidates.""" + candidates = super()._parse_content(response_data) + artifacts: list[ImageArtifact] = [] + + for candidate in candidates: + content = candidate.get("content", {}) + parts = content.get("parts", []) + for part in parts: + inline_data = part.get("inlineData", {}) + base64_data = inline_data.get("data") + if not base64_data: + continue + mime_type = ImageMimeType(inline_data.get("mimeType", "image/png")) + image_bytes = base64.b64decode(base64_data) + artifacts.append(ImageArtifact(data=image_bytes, mime_type=mime_type)) + + if not artifacts: + return ImageArtifact() + if len(artifacts) == 1: + return artifacts[0] + return artifacts + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> ImageFinishReason: + """Parse finish reason from response.""" + finish_reason = super()._parse_finish_reason(response_data) + candidates = response_data.get("candidates", []) + finish_message = candidates[0].get("finishMessage") if candidates else None + return ImageFinishReason(reason=finish_reason.reason, message=finish_message) + + +__all__ = ["GeminiImagesClient"] diff --git a/src/celeste/modalities/images/providers/google/imagen.py b/src/celeste/modalities/images/providers/google/imagen.py new file mode 100644 index 00000000..bd7a1b11 --- /dev/null +++ b/src/celeste/modalities/images/providers/google/imagen.py @@ -0,0 +1,82 @@ +"""Imagen client for Google images modality.""" + +import base64 +from typing import Any, Unpack + +from celeste.artifacts import ImageArtifact +from celeste.mime_types import ImageMimeType +from celeste.parameters import ParameterMapper +from celeste.providers.google.imagen import config as imagen_config +from celeste.providers.google.imagen.client import GoogleImagenClient +from celeste.types import ImageContent + +from ...client import ImagesClient +from ...io import ImageFinishReason, ImageInput, ImageOutput, ImageUsage +from ...parameters import ImageParameters +from .parameters import IMAGEN_PARAMETER_MAPPERS + + +class ImagenImagesClient(GoogleImagenClient, ImagesClient): + """Google Imagen client for images modality (generate).""" + + @classmethod + def parameter_mappers(cls) -> list[ParameterMapper]: + return IMAGEN_PARAMETER_MAPPERS + + async def generate( + self, + prompt: str, + **parameters: Unpack[ImageParameters], + ) -> ImageOutput: + inputs = ImageInput(prompt=prompt) + return await self._predict( + inputs, + endpoint=imagen_config.GoogleImagenEndpoint.CREATE_IMAGE, + **parameters, + ) + + def _init_request(self, inputs: ImageInput) -> dict[str, Any]: + """Initialize request for Imagen API.""" + return { + "instances": [{"prompt": inputs.prompt}], + "parameters": {}, + } + + def _parse_usage(self, response_data: dict[str, Any]) -> ImageUsage: + """Parse usage from response.""" + usage = super()._parse_usage(response_data) + return ImageUsage(**usage) + + def _parse_content( + self, + response_data: dict[str, Any], + **parameters: Unpack[ImageParameters], + ) -> ImageContent: + """Parse image artifacts from Imagen predictions.""" + predictions = super()._parse_content(response_data) + + images: list[ImageArtifact] = [] + for prediction in predictions: + base64_data = prediction.get("bytesBase64Encoded") + if not base64_data: + continue + mime_type = ImageMimeType(prediction.get("mimeType", "image/png")) + image_bytes = base64.b64decode(base64_data) + images.append(ImageArtifact(data=image_bytes, mime_type=mime_type)) + + num_images_requested = parameters.get("num_images") + if num_images_requested == 1: + return images[0] if images else ImageArtifact() + if num_images_requested is not None and num_images_requested > 1: + return images if images else [] + if len(images) == 1: + return images[0] + return images if images else ImageArtifact() + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> ImageFinishReason: + """Imagen API doesn't provide finish reasons.""" + finish_reason = super()._parse_finish_reason(response_data) + return ImageFinishReason(reason=finish_reason.reason) + + +__all__ = ["ImagenImagesClient"] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/google/models.py b/src/celeste/modalities/images/providers/google/models.py similarity index 60% rename from packages/capabilities/image-generation/src/celeste_image_generation/providers/google/models.py rename to src/celeste/modalities/images/providers/google/models.py index 67aec2b4..1d55c368 100644 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/google/models.py +++ b/src/celeste/modalities/images/providers/google/models.py @@ -1,8 +1,10 @@ -"""Google models for image generation.""" +"""Google models for images modality.""" -from celeste import Model, Provider from celeste.constraints import Choice, ImagesConstraint, Range -from celeste_image_generation.parameters import ImageGenerationParameter +from celeste.core import Modality, Operation, Provider +from celeste.models import Model + +from ...parameters import ImageParameter # Imagen API models (instances[].prompt → predictions[]) IMAGEN_MODELS: list[Model] = [ @@ -11,36 +13,39 @@ id="imagen-4.0-generate-001", provider=Provider.GOOGLE, display_name="Imagen 4", + operations={Modality.IMAGES: {Operation.GENERATE}}, parameter_constraints={ - ImageGenerationParameter.NUM_IMAGES: Range(min=1, max=4), - ImageGenerationParameter.ASPECT_RATIO: Choice( + ImageParameter.NUM_IMAGES: Range(min=1, max=4), + ImageParameter.ASPECT_RATIO: Choice( options=["1:1", "3:4", "4:3", "9:16", "16:9"] ), - ImageGenerationParameter.QUALITY: Choice(options=["1K", "2K"]), + ImageParameter.QUALITY: Choice(options=["1K", "2K"]), }, ), Model( id="imagen-4.0-fast-generate-001", provider=Provider.GOOGLE, display_name="Imagen 4 Fast", + operations={Modality.IMAGES: {Operation.GENERATE}}, parameter_constraints={ - ImageGenerationParameter.NUM_IMAGES: Range(min=1, max=4), - ImageGenerationParameter.ASPECT_RATIO: Choice( + ImageParameter.NUM_IMAGES: Range(min=1, max=4), + ImageParameter.ASPECT_RATIO: Choice( options=["1:1", "3:4", "4:3", "9:16", "16:9"] ), - ImageGenerationParameter.QUALITY: Choice(options=["1K"]), + ImageParameter.QUALITY: Choice(options=["1K"]), }, ), Model( id="imagen-4.0-ultra-generate-001", provider=Provider.GOOGLE, display_name="Imagen 4 Ultra", + operations={Modality.IMAGES: {Operation.GENERATE}}, parameter_constraints={ - ImageGenerationParameter.NUM_IMAGES: Range(min=1, max=4), - ImageGenerationParameter.ASPECT_RATIO: Choice( + ImageParameter.NUM_IMAGES: Range(min=1, max=4), + ImageParameter.ASPECT_RATIO: Choice( options=["1:1", "3:4", "4:3", "9:16", "16:9"] ), - ImageGenerationParameter.QUALITY: Choice(options=["1K", "2K"]), + ImageParameter.QUALITY: Choice(options=["1K", "2K"]), }, ), ] @@ -51,8 +56,9 @@ id="gemini-2.5-flash-image", provider=Provider.GOOGLE, display_name="Gemini 2.5 Flash Image", + operations={Modality.IMAGES: {Operation.GENERATE, Operation.EDIT}}, parameter_constraints={ - ImageGenerationParameter.ASPECT_RATIO: Choice( + ImageParameter.ASPECT_RATIO: Choice( options=[ "1:1", "2:3", @@ -66,15 +72,16 @@ "21:9", ] ), - ImageGenerationParameter.REFERENCE_IMAGES: ImagesConstraint(max_count=3), + ImageParameter.REFERENCE_IMAGES: ImagesConstraint(max_count=3), }, ), Model( id="gemini-3-pro-image-preview", provider=Provider.GOOGLE, display_name="Gemini 3 Pro Image (Preview)", + operations={Modality.IMAGES: {Operation.GENERATE, Operation.EDIT}}, parameter_constraints={ - ImageGenerationParameter.ASPECT_RATIO: Choice( + ImageParameter.ASPECT_RATIO: Choice( options=[ "1:1", "2:3", @@ -88,8 +95,8 @@ "21:9", ] ), - ImageGenerationParameter.QUALITY: Choice(options=["1K", "2K", "4K"]), - ImageGenerationParameter.REFERENCE_IMAGES: ImagesConstraint(max_count=14), + ImageParameter.QUALITY: Choice(options=["1K", "2K", "4K"]), + ImageParameter.REFERENCE_IMAGES: ImagesConstraint(max_count=14), }, ), ] @@ -99,3 +106,9 @@ *IMAGEN_MODELS, *GEMINI_MODELS, ] + +__all__ = [ + "GEMINI_MODELS", + "IMAGEN_MODELS", + "MODELS", +] diff --git a/src/celeste/modalities/images/providers/google/parameters.py b/src/celeste/modalities/images/providers/google/parameters.py new file mode 100644 index 00000000..5c15e730 --- /dev/null +++ b/src/celeste/modalities/images/providers/google/parameters.py @@ -0,0 +1,79 @@ +"""Google parameter mappers for images modality.""" + +from celeste.parameters import ParameterMapper +from celeste.providers.google.generate_content.parameters import ( + AspectRatioMapper as _GeminiAspectRatioMapper, +) +from celeste.providers.google.generate_content.parameters import ( + ImageSizeMapper as _GeminiImageSizeMapper, +) +from celeste.providers.google.generate_content.parameters import ( + MediaContentMapper as _GeminiMediaContentMapper, +) +from celeste.providers.google.imagen.parameters import ( + AspectRatioMapper as _ImagenAspectRatioMapper, +) +from celeste.providers.google.imagen.parameters import ( + ImageSizeMapper as _ImagenImageSizeMapper, +) +from celeste.providers.google.imagen.parameters import ( + SampleCountMapper as _ImagenSampleCountMapper, +) + +from ...parameters import ImageParameter + + +class ImagenAspectRatioMapper(_ImagenAspectRatioMapper): + """Map aspect_ratio to Imagen parameters.aspectRatio.""" + + name = ImageParameter.ASPECT_RATIO + + +class ImagenQualityMapper(_ImagenImageSizeMapper): + """Map quality to Imagen parameters.imageSize.""" + + name = ImageParameter.QUALITY + + +class ImagenNumImagesMapper(_ImagenSampleCountMapper): + """Map num_images to Imagen parameters.sampleCount.""" + + name = ImageParameter.NUM_IMAGES + + +IMAGEN_PARAMETER_MAPPERS: list[ParameterMapper] = [ + ImagenAspectRatioMapper(), + ImagenQualityMapper(), + ImagenNumImagesMapper(), +] + + +class GeminiAspectRatioMapper(_GeminiAspectRatioMapper): + """Map aspect_ratio to Gemini generationConfig.imageConfig.aspectRatio.""" + + name = ImageParameter.ASPECT_RATIO + + +class GeminiQualityMapper(_GeminiImageSizeMapper): + """Map quality to Gemini generationConfig.imageConfig.imageSize.""" + + name = ImageParameter.QUALITY + + +class GeminiReferenceImagesMapper(_GeminiMediaContentMapper): + """Map reference_images to Gemini contents.parts.""" + + name = ImageParameter.REFERENCE_IMAGES + + +GEMINI_PARAMETER_MAPPERS: list[ParameterMapper] = [ + GeminiAspectRatioMapper(), + GeminiQualityMapper(), + GeminiReferenceImagesMapper(), +] + + +__all__ = [ + "GEMINI_PARAMETER_MAPPERS", + "IMAGEN_PARAMETER_MAPPERS", +] diff --git a/src/celeste/modalities/images/providers/openai/__init__.py b/src/celeste/modalities/images/providers/openai/__init__.py new file mode 100644 index 00000000..5a1e11f2 --- /dev/null +++ b/src/celeste/modalities/images/providers/openai/__init__.py @@ -0,0 +1,6 @@ +"""OpenAI provider for images modality.""" + +from .client import OpenAIImagesClient +from .models import MODELS + +__all__ = ["MODELS", "OpenAIImagesClient"] diff --git a/src/celeste/modalities/images/providers/openai/client.py b/src/celeste/modalities/images/providers/openai/client.py new file mode 100644 index 00000000..d7b6b35d --- /dev/null +++ b/src/celeste/modalities/images/providers/openai/client.py @@ -0,0 +1,166 @@ +"""OpenAI images client.""" + +import base64 +from typing import Any, Unpack + +from celeste.artifacts import ImageArtifact +from celeste.parameters import ParameterMapper +from celeste.providers.openai.images import config +from celeste.providers.openai.images.client import ( + OpenAIImagesClient as OpenAIImagesMixin, +) +from celeste.providers.openai.images.streaming import ( + OpenAIImagesStream as _OpenAIImagesStream, +) + +from ...client import ImagesClient +from ...io import ( + ImageChunk, + ImageFinishReason, + ImageInput, + ImageOutput, + ImageUsage, +) +from ...parameters import ImageParameters +from ...streaming import ImagesStream +from .parameters import OPENAI_PARAMETER_MAPPERS + + +class OpenAIImagesStream(_OpenAIImagesStream, ImagesStream): + """OpenAI streaming for images modality.""" + + def _parse_chunk_usage(self, event_data: dict[str, Any]) -> ImageUsage | None: + """Parse and wrap usage from SSE event.""" + usage = super()._parse_chunk_usage(event_data) + if usage: + return ImageUsage(**usage) + return None + + def _parse_chunk_finish_reason( + self, event_data: dict[str, Any] + ) -> ImageFinishReason | None: + """Parse and wrap finish reason from SSE event.""" + finish_reason = super()._parse_chunk_finish_reason(event_data) + if finish_reason: + return ImageFinishReason(reason=finish_reason.reason) + return None + + def _parse_chunk(self, event_data: dict[str, Any]) -> ImageChunk | None: + """Parse one SSE event into a typed chunk.""" + b64_json = self._parse_chunk_content(event_data) + if not b64_json: + usage = self._parse_chunk_usage(event_data) + finish_reason = self._parse_chunk_finish_reason(event_data) + if usage is None and finish_reason is None: + return None + # Chunk with usage/finish_reason only (no image) + return ImageChunk( + content=ImageArtifact(data=b""), + finish_reason=finish_reason, + usage=usage, + metadata={"event_data": event_data}, + ) + + image_data = base64.b64decode(b64_json) + artifact = ImageArtifact(data=image_data) + + return ImageChunk( + content=artifact, + finish_reason=self._parse_chunk_finish_reason(event_data), + usage=self._parse_chunk_usage(event_data), + metadata={"event_data": event_data}, + ) + + def _aggregate_content(self, chunks: list[ImageChunk]) -> ImageArtifact: + """Aggregate image content from chunks.""" + return chunks[-1].content + + def _aggregate_event_data(self, chunks: list[ImageChunk]) -> list[dict[str, Any]]: + """Collect metadata events (skip content-only events).""" + events: list[dict[str, Any]] = [] + for chunk in chunks: + event_data = chunk.metadata.get("event_data") + if isinstance(event_data, dict): + events.append(event_data) + return events + + +class OpenAIImagesClient(OpenAIImagesMixin, ImagesClient): + """OpenAI images client.""" + + @classmethod + def parameter_mappers(cls) -> list[ParameterMapper]: + return OPENAI_PARAMETER_MAPPERS + + def _init_request(self, inputs: ImageInput) -> dict[str, Any]: + """Initialize request, keeping ImageArtifact for multipart handling.""" + request: dict[str, Any] = {"prompt": inputs.prompt} + if inputs.image is not None: + # Keep as ImageArtifact - _make_multipart_request handles encoding + request["image"] = inputs.image + return request + + async def generate( + self, + prompt: str, + **parameters: Unpack[ImageParameters], + ) -> ImageOutput: + """Generate images from prompt.""" + inputs = ImageInput(prompt=prompt) + return await self._predict( + inputs, + endpoint=config.OpenAIImagesEndpoint.CREATE_IMAGE, + **parameters, + ) + + async def edit( + self, + image: ImageArtifact, + prompt: str, + **parameters: Unpack[ImageParameters], + ) -> ImageOutput: + """Edit an image with text instructions.""" + inputs = ImageInput(image=image, prompt=prompt) + return await self._predict( + inputs, + endpoint=config.OpenAIImagesEndpoint.CREATE_EDIT, + **parameters, + ) + + def _parse_usage(self, response_data: dict[str, Any]) -> ImageUsage: + """Parse usage from response.""" + usage = super()._parse_usage(response_data) + return ImageUsage(**usage) + + def _parse_content( + self, + response_data: dict[str, Any], + **parameters: Unpack[ImageParameters], + ) -> ImageArtifact: + """Parse content from response.""" + data = super()._parse_content(response_data) + image_data = data[0] + + b64_json = image_data.get("b64_json") + if b64_json: + image_bytes = base64.b64decode(b64_json) + return ImageArtifact(data=image_bytes) + + url = image_data.get("url") + if url: + return ImageArtifact(url=url) + + msg = "No image URL or base64 data in response" + raise ValueError(msg) + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> ImageFinishReason: + """Parse finish reason from response.""" + finish_reason = super()._parse_finish_reason(response_data) + return ImageFinishReason(reason=finish_reason.reason) + + def _stream_class(self) -> type[ImagesStream]: + """Return the Stream class for this provider.""" + return OpenAIImagesStream + + +__all__ = ["OpenAIImagesClient", "OpenAIImagesStream"] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/openai/models.py b/src/celeste/modalities/images/providers/openai/models.py similarity index 52% rename from packages/capabilities/image-generation/src/celeste_image_generation/providers/openai/models.py rename to src/celeste/modalities/images/providers/openai/models.py index c2b54a04..f89705d2 100644 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/openai/models.py +++ b/src/celeste/modalities/images/providers/openai/models.py @@ -1,16 +1,19 @@ -"""OpenAI models for image generation.""" +"""OpenAI models for images modality.""" -from celeste import Model, Provider from celeste.constraints import Choice, Range -from celeste_image_generation.parameters import ImageGenerationParameter +from celeste.core import Modality, Operation, Provider +from celeste.models import Model + +from ...parameters import ImageParameter MODELS: list[Model] = [ Model( id="dall-e-2", provider=Provider.OPENAI, display_name="DALL-E 2", + operations={Modality.IMAGES: {Operation.GENERATE, Operation.EDIT}}, parameter_constraints={ - ImageGenerationParameter.ASPECT_RATIO: Choice( + ImageParameter.ASPECT_RATIO: Choice( options=["256x256", "512x512", "1024x1024"] ), }, @@ -19,55 +22,53 @@ id="dall-e-3", provider=Provider.OPENAI, display_name="DALL-E 3", + operations={Modality.IMAGES: {Operation.GENERATE}}, parameter_constraints={ - ImageGenerationParameter.ASPECT_RATIO: Choice( + ImageParameter.ASPECT_RATIO: Choice( options=["1024x1024", "1792x1024", "1024x1792"] ), - ImageGenerationParameter.QUALITY: Choice(options=["standard", "hd"]), + ImageParameter.QUALITY: Choice(options=["standard", "hd"]), }, ), Model( id="gpt-image-1", provider=Provider.OPENAI, display_name="GPT Image 1", + operations={Modality.IMAGES: {Operation.GENERATE, Operation.EDIT}}, streaming=True, parameter_constraints={ - ImageGenerationParameter.PARTIAL_IMAGES: Range(min=0, max=3), - ImageGenerationParameter.ASPECT_RATIO: Choice( + ImageParameter.PARTIAL_IMAGES: Range(min=0, max=3), + ImageParameter.ASPECT_RATIO: Choice( options=["1024x1024", "1536x1024", "1024x1536", "auto"] ), - ImageGenerationParameter.QUALITY: Choice( - options=["low", "medium", "high", "auto"] - ), + ImageParameter.QUALITY: Choice(options=["low", "medium", "high", "auto"]), }, ), Model( id="gpt-image-1-mini", provider=Provider.OPENAI, display_name="GPT Image 1 Mini", + operations={Modality.IMAGES: {Operation.GENERATE, Operation.EDIT}}, streaming=True, parameter_constraints={ - ImageGenerationParameter.PARTIAL_IMAGES: Range(min=0, max=3), - ImageGenerationParameter.ASPECT_RATIO: Choice( + ImageParameter.PARTIAL_IMAGES: Range(min=0, max=3), + ImageParameter.ASPECT_RATIO: Choice( options=["1024x1024", "1024x1536", "1536x1024", "auto"] ), - ImageGenerationParameter.QUALITY: Choice( - options=["low", "medium", "high", "auto"] - ), + ImageParameter.QUALITY: Choice(options=["low", "medium", "high", "auto"]), }, ), Model( id="gpt-image-1.5", provider=Provider.OPENAI, display_name="GPT Image 1.5", + operations={Modality.IMAGES: {Operation.GENERATE, Operation.EDIT}}, streaming=False, parameter_constraints={ - ImageGenerationParameter.ASPECT_RATIO: Choice( + ImageParameter.ASPECT_RATIO: Choice( options=["1024x1024", "1536x1024", "1024x1536", "auto"] ), - ImageGenerationParameter.QUALITY: Choice( - options=["low", "medium", "high", "auto"] - ), + ImageParameter.QUALITY: Choice(options=["low", "medium", "high", "auto"]), }, ), ] diff --git a/src/celeste/modalities/images/providers/openai/parameters.py b/src/celeste/modalities/images/providers/openai/parameters.py new file mode 100644 index 00000000..84485d6c --- /dev/null +++ b/src/celeste/modalities/images/providers/openai/parameters.py @@ -0,0 +1,41 @@ +"""OpenAI parameter mappers for images.""" + +from celeste.parameters import ParameterMapper +from celeste.providers.openai.images.parameters import ( + PartialImagesMapper as _PartialImagesMapper, +) +from celeste.providers.openai.images.parameters import ( + QualityMapper as _QualityMapper, +) +from celeste.providers.openai.images.parameters import ( + SizeMapper as _SizeMapper, +) + +from ...parameters import ImageParameter + + +class AspectRatioMapper(_SizeMapper): + """Map aspect_ratio to OpenAI's size parameter.""" + + name = ImageParameter.ASPECT_RATIO + + +class PartialImagesMapper(_PartialImagesMapper): + """Map partial_images to OpenAI's partial_images parameter.""" + + name = ImageParameter.PARTIAL_IMAGES + + +class QualityMapper(_QualityMapper): + """Map quality to OpenAI's quality parameter.""" + + name = ImageParameter.QUALITY + + +OPENAI_PARAMETER_MAPPERS: list[ParameterMapper] = [ + AspectRatioMapper(), + PartialImagesMapper(), + QualityMapper(), +] + +__all__ = ["OPENAI_PARAMETER_MAPPERS"] diff --git a/src/celeste/modalities/images/streaming.py b/src/celeste/modalities/images/streaming.py new file mode 100644 index 00000000..d8094511 --- /dev/null +++ b/src/celeste/modalities/images/streaming.py @@ -0,0 +1,90 @@ +"""Images streaming primitives.""" + +from abc import abstractmethod +from collections.abc import AsyncIterator, Callable +from typing import Any, Unpack + +from celeste.artifacts import ImageArtifact +from celeste.client import ModalityClient +from celeste.streaming import Stream +from celeste.types import ImageContent + +from .io import ImageChunk, ImageFinishReason, ImageOutput, ImageUsage +from .parameters import ImageParameters + + +class ImagesStream(Stream[ImageOutput, ImageParameters, ImageChunk]): + """Streaming for images modality.""" + + def __init__( + self, + sse_iterator: AsyncIterator[dict[str, Any]], + transform_output: Callable[..., ImageContent], + client: ModalityClient, + **parameters: Unpack[ImageParameters], + ) -> None: + """Initialize stream with output transformation support.""" + super().__init__(sse_iterator, **parameters) + self._transform_output = transform_output + self._client = client + + @abstractmethod + def _aggregate_content(self, chunks: list[ImageChunk]) -> ImageArtifact: + """Aggregate content from chunks into raw content (modality-specific).""" + ... + + def _aggregate_usage(self, chunks: list[ImageChunk]) -> ImageUsage: + """Aggregate usage across chunks (universal).""" + for chunk in reversed(chunks): + if chunk.usage: + return chunk.usage + return ImageUsage() + + def _aggregate_finish_reason( + self, + chunks: list[ImageChunk], + ) -> ImageFinishReason | None: + """Aggregate finish reason across chunks (universal).""" + for chunk in reversed(chunks): + if chunk.finish_reason: + return chunk.finish_reason + return None + + @abstractmethod + def _aggregate_event_data(self, chunks: list[ImageChunk]) -> list[dict[str, Any]]: + """Collect raw events (filtering happens in _build_stream_metadata).""" + ... + + def _build_stream_metadata( + self, raw_events: list[dict[str, Any]] + ) -> dict[str, Any]: + """Build streaming metadata. Provider API Stream overrides to filter content.""" + return { + "model": self._client.model.id, + "provider": self._client.provider, + "modality": self._client.modality, + "raw_events": raw_events, + } + + def _parse_output( + self, + chunks: list[ImageChunk], + **parameters: Unpack[ImageParameters], + ) -> ImageOutput: + """Assemble chunks into final output.""" + if not chunks: + msg = "No chunks received from stream" + raise ValueError(msg) + + raw_content = self._aggregate_content(chunks) + content: ImageContent = self._transform_output(raw_content, **parameters) + raw_events = self._aggregate_event_data(chunks) + return ImageOutput( + content=content, + usage=self._aggregate_usage(chunks), + finish_reason=self._aggregate_finish_reason(chunks), + metadata=self._build_stream_metadata(raw_events), + ) + + +__all__ = ["ImagesStream"] diff --git a/src/celeste/modalities/text/__init__.py b/src/celeste/modalities/text/__init__.py new file mode 100644 index 00000000..8127a5a9 --- /dev/null +++ b/src/celeste/modalities/text/__init__.py @@ -0,0 +1,25 @@ +"""Celeste Text modality.""" + +from .client import TextClient, TextStreamNamespace +from .io import ( + TextChunk, + TextFinishReason, + TextInput, + TextOutput, + TextUsage, +) +from .parameters import TextParameter, TextParameters +from .streaming import TextStream + +__all__ = [ + "TextChunk", + "TextClient", + "TextFinishReason", + "TextInput", + "TextOutput", + "TextParameter", + "TextParameters", + "TextStream", + "TextStreamNamespace", + "TextUsage", +] diff --git a/src/celeste/modalities/text/client.py b/src/celeste/modalities/text/client.py new file mode 100644 index 00000000..c4350343 --- /dev/null +++ b/src/celeste/modalities/text/client.py @@ -0,0 +1,236 @@ +"""Text modality client.""" + +from typing import Unpack + +from asgiref.sync import async_to_sync + +from celeste.client import ModalityClient +from celeste.core import InputType, Modality +from celeste.types import AudioContent, ImageContent, TextContent, VideoContent + +from .io import TextInput, TextOutput +from .parameters import TextParameters +from .streaming import TextStream + + +class TextClient(ModalityClient[TextInput, TextOutput, TextParameters, TextContent]): + """Base text client. + + Providers implement operation methods (generate, analyze). + """ + + modality: Modality = Modality.TEXT + + @classmethod + def _output_class(cls) -> type[TextOutput]: + """Return the Output class for text modality.""" + return TextOutput + + def _check_media_support( + self, + image: ImageContent | None, + video: VideoContent | None, + audio: AudioContent | None, + ) -> None: + """Check model supports the provided media types. + + Raises: + NotImplementedError: If media type is provided but model doesn't support it. + """ + if image is not None and InputType.IMAGE not in self.model.optional_input_types: + msg = f"Model {self.model.id} does not support image input" + raise NotImplementedError(msg) + if video is not None and InputType.VIDEO not in self.model.optional_input_types: + msg = f"Model {self.model.id} does not support video input" + raise NotImplementedError(msg) + if audio is not None and InputType.AUDIO not in self.model.optional_input_types: + msg = f"Model {self.model.id} does not support audio input" + raise NotImplementedError(msg) + + @property + def stream(self) -> "TextStreamNamespace": + """Streaming namespace for text operations.""" + return TextStreamNamespace(self) + + @property + def sync(self) -> "TextSyncNamespace": + """Sync namespace for text operations.""" + return TextSyncNamespace(self) + + +class TextStreamNamespace: + """Streaming namespace for text operations. + + Provides `client.stream.generate()` and `client.stream.analyze()`. + """ + + def __init__(self, client: TextClient) -> None: + self._client = client + + def generate( + self, + prompt: str, + **parameters: Unpack[TextParameters], + ) -> TextStream: + """Stream text generation. + + Usage: + async for chunk in client.stream.generate("Hello"): + print(chunk.content) + """ + inputs = TextInput(prompt=prompt) + return self._client._stream( + inputs, + stream_class=self._client._stream_class(), + **parameters, + ) + + def analyze( + self, + prompt: str, + *, + image: ImageContent | None = None, + video: VideoContent | None = None, + audio: AudioContent | None = None, + **parameters: Unpack[TextParameters], + ) -> TextStream: + """Stream media analysis (image, video, or audio). + + Usage: + async for chunk in client.stream.analyze("Describe", image=img): + print(chunk.content) + + async for chunk in client.stream.analyze("Describe", video=vid): + print(chunk.content) + + async for chunk in client.stream.analyze("Transcribe", audio=aud): + print(chunk.content) + """ + self._client._check_media_support(image=image, video=video, audio=audio) + inputs = TextInput(prompt=prompt, image=image, video=video, audio=audio) + return self._client._stream( + inputs, + stream_class=self._client._stream_class(), + **parameters, + ) + + +class TextSyncNamespace: + """Sync namespace for text operations. + + Provides `client.sync.generate()` and `client.sync.analyze()`. + """ + + def __init__(self, client: TextClient) -> None: + self._client = client + + def generate( + self, + prompt: str, + **parameters: Unpack[TextParameters], + ) -> TextOutput: + """Blocking text generation. + + Usage: + result = client.sync.generate("Hello") + print(result.content) + """ + inputs = TextInput(prompt=prompt) + return async_to_sync(self._client._predict)(inputs, **parameters) + + def analyze( + self, + prompt: str, + *, + image: ImageContent | None = None, + video: VideoContent | None = None, + audio: AudioContent | None = None, + **parameters: Unpack[TextParameters], + ) -> TextOutput: + """Blocking media analysis (image, video, or audio). + + Usage: + result = client.sync.analyze("Describe", image=img) + print(result.content) + + result = client.sync.analyze("Describe", video=vid) + print(result.content) + + result = client.sync.analyze("Transcribe", audio=aud) + print(result.content) + """ + self._client._check_media_support(image=image, video=video, audio=audio) + inputs = TextInput(prompt=prompt, image=image, video=video, audio=audio) + return async_to_sync(self._client._predict)(inputs, **parameters) + + @property + def stream(self) -> "TextSyncStreamNamespace": + """Sync streaming namespace.""" + return TextSyncStreamNamespace(self._client) + + +class TextSyncStreamNamespace: + """Sync streaming namespace - returns Stream instance with sync iteration support.""" + + def __init__(self, client: TextClient) -> None: + self._client = client + + def generate( + self, + prompt: str, + **parameters: Unpack[TextParameters], + ) -> TextStream: + """Sync streaming text generation. + + Returns Stream instance that supports both async and sync iteration. + + Usage: + stream = client.sync.stream.generate("Hello") + for chunk in stream: # Sync iteration (bridges async internally) + print(chunk.content, end="") + print(stream.output.usage) + """ + # Return same stream as async version - __iter__/__next__ handle sync iteration + return self._client.stream.generate(prompt, **parameters) + + def analyze( + self, + prompt: str, + *, + image: ImageContent | None = None, + video: VideoContent | None = None, + audio: AudioContent | None = None, + **parameters: Unpack[TextParameters], + ) -> TextStream: + """Sync streaming media analysis (image, video, or audio). + + Returns Stream instance that supports both async and sync iteration. + + Usage: + stream = client.sync.stream.analyze("Describe", image=img) + for chunk in stream: # Sync iteration (bridges async internally) + print(chunk.content, end="") + print(stream.output.usage) + + stream = client.sync.stream.analyze("Describe", video=vid) + for chunk in stream: + print(chunk.content, end="") + print(stream.output.usage) + + stream = client.sync.stream.analyze("Transcribe", audio=aud) + for chunk in stream: + print(chunk.content, end="") + print(stream.output.usage) + """ + # Return same stream as async version - __iter__/__next__ handle sync iteration + return self._client.stream.analyze( + prompt, image=image, video=video, audio=audio, **parameters + ) + + +__all__ = [ + "TextClient", + "TextStreamNamespace", + "TextSyncNamespace", + "TextSyncStreamNamespace", +] diff --git a/src/celeste/modalities/text/io.py b/src/celeste/modalities/text/io.py new file mode 100644 index 00000000..a5950ed7 --- /dev/null +++ b/src/celeste/modalities/text/io.py @@ -0,0 +1,62 @@ +"""IO types for text modality. + +Text modality handles: +- Text generation (prompt → text) +- Analysis of any media type (text/image/video/audio + prompt → text) + +Types are unified per-modality since generate and analyze produce identical outputs. +""" + +from pydantic import Field + +from celeste.io import Chunk, FinishReason, Input, Output, Usage +from celeste.types import AudioContent, ImageContent, TextContent, VideoContent + + +class TextInput(Input): + """Input for text operations.""" + + prompt: str + text: str | list[str] | None = None + image: ImageContent | None = None + video: VideoContent | None = None + audio: AudioContent | None = None + + +class TextFinishReason(FinishReason): + """Text finish reason.""" + + reason: str | None = None + message: str | None = None + + +class TextUsage(Usage): + """Text usage metrics.""" + + total_tokens: int | None = None + input_tokens: int | None = None + output_tokens: int | None = None + reasoning_tokens: int | None = None + + +class TextOutput(Output[TextContent]): + """Output from text operations.""" + + usage: TextUsage = Field(default_factory=TextUsage) + finish_reason: TextFinishReason | None = None + + +class TextChunk(Chunk[str]): + """Chunk for text streaming.""" + + finish_reason: TextFinishReason | None = None + usage: TextUsage | None = None + + +__all__ = [ + "TextChunk", + "TextFinishReason", + "TextInput", + "TextOutput", + "TextUsage", +] diff --git a/src/celeste/modalities/text/models.py b/src/celeste/modalities/text/models.py new file mode 100644 index 00000000..f830e2cd --- /dev/null +++ b/src/celeste/modalities/text/models.py @@ -0,0 +1,25 @@ +"""Aggregated models for text modality.""" + +from celeste.models import Model + +from .providers.anthropic.models import MODELS as ANTHROPIC_MODELS +from .providers.cohere.models import MODELS as COHERE_MODELS +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.mistral.models import MODELS as MISTRAL_MODELS +from .providers.moonshot.models import MODELS as MOONSHOT_MODELS +from .providers.openai.models import MODELS as OPENAI_MODELS +from .providers.xai.models import MODELS as XAI_MODELS + +MODELS: list[Model] = [ + *ANTHROPIC_MODELS, + *COHERE_MODELS, + *DEEPSEEK_MODELS, + *GOOGLE_MODELS, + *GROQ_MODELS, + *MISTRAL_MODELS, + *MOONSHOT_MODELS, + *OPENAI_MODELS, + *XAI_MODELS, +] diff --git a/src/celeste/modalities/text/parameters.py b/src/celeste/modalities/text/parameters.py new file mode 100644 index 00000000..e40c7dd5 --- /dev/null +++ b/src/celeste/modalities/text/parameters.py @@ -0,0 +1,58 @@ +"""Parameters for text modality. + +Unified parameters for all text operations (generate, analyze). +Model `parameter_constraints` enforces which parameters are valid per model. +""" + +from enum import StrEnum + +from pydantic import BaseModel + +from celeste.parameters import Parameters + + +class TextParameter(StrEnum): + """Unified parameter names for text modality.""" + + # Common parameters + TEMPERATURE = "temperature" + MAX_TOKENS = "max_tokens" + SEED = "seed" + + # Text-specific parameters + THINKING_BUDGET = "thinking_budget" + THINKING_LEVEL = "thinking_level" + OUTPUT_SCHEMA = "output_schema" + WEB_SEARCH = "web_search" + VERBOSITY = "verbosity" + X_SEARCH = "x_search" + CODE_EXECUTION = "code_execution" + + # Media input declarations (for optional_input_types) + IMAGE = "image" + VIDEO = "video" + AUDIO = "audio" + + +class TextParameters(Parameters): + """Parameters for text operations.""" + + # Common parameters + temperature: float + max_tokens: int + seed: int + + # Text-specific parameters + thinking_budget: int | str + thinking_level: str + output_schema: type[BaseModel] + web_search: bool + verbosity: str + x_search: bool + code_execution: bool + + +__all__ = [ + "TextParameter", + "TextParameters", +] diff --git a/src/celeste/modalities/text/providers/__init__.py b/src/celeste/modalities/text/providers/__init__.py new file mode 100644 index 00000000..6343c862 --- /dev/null +++ b/src/celeste/modalities/text/providers/__init__.py @@ -0,0 +1,26 @@ +"""Text providers.""" + +from celeste.core import Provider + +from ..client import TextClient +from .anthropic import AnthropicTextClient +from .cohere import CohereTextClient +from .deepseek import DeepSeekTextClient +from .google import GoogleTextClient +from .groq import GroqTextClient +from .mistral import MistralTextClient +from .moonshot import MoonshotTextClient +from .openai import OpenAITextClient +from .xai import XAITextClient + +PROVIDERS: dict[Provider, type[TextClient]] = { + Provider.ANTHROPIC: AnthropicTextClient, + Provider.COHERE: CohereTextClient, + Provider.DEEPSEEK: DeepSeekTextClient, + Provider.GOOGLE: GoogleTextClient, + Provider.GROQ: GroqTextClient, + Provider.MISTRAL: MistralTextClient, + Provider.MOONSHOT: MoonshotTextClient, + Provider.OPENAI: OpenAITextClient, + Provider.XAI: XAITextClient, +} diff --git a/src/celeste/modalities/text/providers/anthropic/__init__.py b/src/celeste/modalities/text/providers/anthropic/__init__.py new file mode 100644 index 00000000..fa6e96c1 --- /dev/null +++ b/src/celeste/modalities/text/providers/anthropic/__init__.py @@ -0,0 +1,6 @@ +"""Anthropic provider for text modality.""" + +from .client import AnthropicTextClient +from .models import MODELS + +__all__ = ["MODELS", "AnthropicTextClient"] diff --git a/src/celeste/modalities/text/providers/anthropic/client.py b/src/celeste/modalities/text/providers/anthropic/client.py new file mode 100644 index 00000000..9b0a979c --- /dev/null +++ b/src/celeste/modalities/text/providers/anthropic/client.py @@ -0,0 +1,191 @@ +"""Anthropic text client (modality).""" + +import base64 +from typing import Any, Unpack + +from celeste.artifacts import ImageArtifact +from celeste.mime_types import ImageMimeType +from celeste.parameters import ParameterMapper +from celeste.providers.anthropic.messages.client import AnthropicMessagesClient +from celeste.providers.anthropic.messages.streaming import ( + AnthropicMessagesStream as _AnthropicMessagesStream, +) +from celeste.types import ImageContent, TextContent, VideoContent +from celeste.utils import detect_mime_type + +from ...client import TextClient +from ...io import ( + TextChunk, + TextFinishReason, + TextInput, + TextOutput, + TextUsage, +) +from ...parameters import TextParameters +from ...streaming import TextStream +from .parameters import ANTHROPIC_PARAMETER_MAPPERS + + +class AnthropicTextStream(_AnthropicMessagesStream, TextStream): + """Anthropic streaming for text modality.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._message_start: dict[str, Any] | None = None + + def _parse_chunk_usage(self, event_data: dict[str, Any]) -> TextUsage | None: + """Parse and wrap usage from SSE event.""" + usage = super()._parse_chunk_usage(event_data) + if usage: + return TextUsage(**usage) + return None + + def _parse_chunk_finish_reason( + self, event_data: dict[str, Any] + ) -> TextFinishReason | None: + """Parse and wrap finish reason from SSE event.""" + finish_reason = super()._parse_chunk_finish_reason(event_data) + if finish_reason: + return TextFinishReason(reason=finish_reason.reason) + return None + + def _parse_chunk(self, event_data: dict[str, Any]) -> TextChunk | None: + """Parse one SSE event into a typed chunk.""" + event_type = event_data.get("type") + if event_type == "message_start": + message = event_data.get("message") + if isinstance(message, dict): + self._message_start = message + return None + + content = self._parse_chunk_content(event_data) + if content is None: + usage = self._parse_chunk_usage(event_data) + finish_reason = self._parse_chunk_finish_reason(event_data) + if usage is None and finish_reason is None: + return None + content = "" + + return TextChunk( + content=content, + finish_reason=self._parse_chunk_finish_reason(event_data), + usage=self._parse_chunk_usage(event_data), + metadata={"event_data": event_data}, + ) + + def _aggregate_content(self, chunks: list[TextChunk]) -> str: + """Aggregate streamed text content.""" + return "".join(chunk.content for chunk in chunks) + + def _aggregate_event_data(self, chunks: list[TextChunk]) -> list[dict[str, Any]]: + """Collect raw events (filtering happens in _build_stream_metadata).""" + events: list[dict[str, Any]] = [] + if self._message_start is not None: + events.append({"type": "message_start", "message": self._message_start}) + for chunk in chunks: + event_data = chunk.metadata.get("event_data") + if isinstance(event_data, dict): + events.append(event_data) + return events + + +class AnthropicTextClient(AnthropicMessagesClient, TextClient): + """Anthropic text client.""" + + @classmethod + def parameter_mappers(cls) -> list[ParameterMapper]: + return ANTHROPIC_PARAMETER_MAPPERS + + async def generate( + self, + prompt: str, + **parameters: Unpack[TextParameters], + ) -> TextOutput: + """Generate text from prompt.""" + inputs = TextInput(prompt=prompt) + return await self._predict(inputs, **parameters) + + async def analyze( + self, + prompt: str, + *, + image: ImageContent | None = None, + video: VideoContent | None = None, + **parameters: Unpack[TextParameters], + ) -> TextOutput: + """Analyze image(s) or video(s) with prompt.""" + inputs = TextInput(prompt=prompt, image=image, video=video) + return await self._predict(inputs, **parameters) + + def _init_request(self, inputs: TextInput) -> dict[str, Any]: + """Initialize request from Anthropic Messages API format.""" + if inputs.image is None: + content: str | list[dict[str, Any]] = inputs.prompt + else: + images = inputs.image if isinstance(inputs.image, list) else [inputs.image] + content = [] + for img in images: + source = self._build_image_source(img) + content.append({"type": "image", "source": source}) + content.append({"type": "text", "text": inputs.prompt}) + + return {"messages": [{"role": "user", "content": content}]} + + def _build_image_source(self, img: ImageArtifact) -> dict[str, Any]: + """Build Anthropic image source dict from ImageArtifact.""" + # Data URL: parse into media_type + base64 data + if img.url and img.url.startswith("data:") and "," in img.url: + header, data = img.url.split(",", 1) + media_type = ( + header.removeprefix("data:").split(";", 1)[0] or ImageMimeType.JPEG + ) + return {"type": "base64", "media_type": str(media_type), "data": data} + + # Regular URL: pass through + if img.url: + return {"type": "url", "url": img.url} + + # Bytes or file path: encode to base64 + image_bytes = img.get_bytes() + mime = img.mime_type or detect_mime_type(image_bytes) + mime_str = str(mime) if mime else None + + base64_data = base64.b64encode(image_bytes).decode("utf-8") + return { + "type": "base64", + "media_type": mime_str, + "data": base64_data, + } + + def _parse_usage(self, response_data: dict[str, Any]) -> TextUsage: + """Parse usage from response.""" + usage = super()._parse_usage(response_data) + return TextUsage(**usage) + + def _parse_content( + self, + response_data: dict[str, Any], + **parameters: Unpack[TextParameters], + ) -> TextContent: + """Parse content from response.""" + content = super()._parse_content(response_data) + + text_content = "" + for content_block in content: + if content_block.get("type") == "text": + text_content = content_block.get("text") or "" + break + + return self._transform_output(text_content, **parameters) + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> TextFinishReason: + """Parse finish reason from response.""" + finish_reason = super()._parse_finish_reason(response_data) + return TextFinishReason(reason=finish_reason.reason) + + def _stream_class(self) -> type[TextStream]: + """Return the Stream class for this provider.""" + return AnthropicTextStream + + +__all__ = ["AnthropicTextClient", "AnthropicTextStream"] diff --git a/src/celeste/modalities/text/providers/anthropic/models.py b/src/celeste/modalities/text/providers/anthropic/models.py new file mode 100644 index 00000000..7315430f --- /dev/null +++ b/src/celeste/modalities/text/providers/anthropic/models.py @@ -0,0 +1,86 @@ +"""Anthropic 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="claude-sonnet-4-5", + provider=Provider.ANTHROPIC, + display_name="Claude Sonnet 4.5", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, + streaming=True, + parameter_constraints={ + Parameter.MAX_TOKENS: Range(min=1, max=64000), + TextParameter.THINKING_BUDGET: Range(min=-1, max=64000), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.IMAGE: ImagesConstraint(), + }, + ), + Model( + id="claude-haiku-4-5", + provider=Provider.ANTHROPIC, + display_name="Claude Haiku 4.5", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, + streaming=True, + parameter_constraints={ + Parameter.MAX_TOKENS: Range(min=1, max=64000), + TextParameter.THINKING_BUDGET: Range(min=-1, max=32000), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.IMAGE: ImagesConstraint(), + }, + ), + Model( + id="claude-opus-4-1", + provider=Provider.ANTHROPIC, + display_name="Claude Opus 4.1", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, + streaming=True, + parameter_constraints={ + Parameter.MAX_TOKENS: Range(min=1, max=32000), + TextParameter.THINKING_BUDGET: Range(min=-1, max=32000), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.IMAGE: ImagesConstraint(), + }, + ), + Model( + id="claude-opus-4-5", + provider=Provider.ANTHROPIC, + display_name="Claude Opus 4.5", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, + streaming=True, + parameter_constraints={ + Parameter.MAX_TOKENS: Range(min=1, max=64000), + TextParameter.THINKING_BUDGET: Range(min=-1, max=32000), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.IMAGE: ImagesConstraint(), + }, + ), + Model( + id="claude-sonnet-4-20250514", + provider=Provider.ANTHROPIC, + display_name="Claude Sonnet 4", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, + streaming=True, + parameter_constraints={ + Parameter.MAX_TOKENS: Range(min=1, max=64000), + TextParameter.THINKING_BUDGET: Range(min=-1, max=64000), + TextParameter.IMAGE: ImagesConstraint(), + }, + ), + Model( + id="claude-opus-4-20250514", + provider=Provider.ANTHROPIC, + display_name="Claude Opus 4", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, + streaming=True, + parameter_constraints={ + Parameter.MAX_TOKENS: Range(min=1, max=32000), + TextParameter.THINKING_BUDGET: Range(min=-1, max=32000), + TextParameter.IMAGE: ImagesConstraint(), + }, + ), +] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/anthropic/parameters.py b/src/celeste/modalities/text/providers/anthropic/parameters.py similarity index 57% rename from packages/capabilities/text-generation/src/celeste_text_generation/providers/anthropic/parameters.py rename to src/celeste/modalities/text/providers/anthropic/parameters.py index 4cd0ee9c..563a794c 100644 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/anthropic/parameters.py +++ b/src/celeste/modalities/text/providers/anthropic/parameters.py @@ -1,38 +1,41 @@ -"""Anthropic Messages parameter mappers for text generation.""" +"""Anthropic parameter mappers for text.""" from typing import Any -from celeste_anthropic.messages.parameters import ( +from celeste.models import Model +from celeste.parameters import ParameterMapper +from celeste.providers.anthropic.messages.parameters import ( MaxTokensMapper as _MaxTokensMapper, ) -from celeste_anthropic.messages.parameters import ( - OutputSchemaMapper as _OutputSchemaMapper, +from celeste.providers.anthropic.messages.parameters import ( + OutputFormatMapper as _OutputFormatMapper, ) -from celeste_anthropic.messages.parameters import ( +from celeste.providers.anthropic.messages.parameters import ( TemperatureMapper as _TemperatureMapper, ) -from celeste_anthropic.messages.parameters import ( +from celeste.providers.anthropic.messages.parameters import ( ThinkingMapper as _ThinkingMapper, ) -from celeste.core import Parameter -from celeste.models import Model -from celeste.parameters import ParameterMapper -from celeste_text_generation.parameters import TextGenerationParameter +from ...parameters import TextParameter class TemperatureMapper(_TemperatureMapper): - name = Parameter.TEMPERATURE + """Map temperature to Anthropic's temperature parameter.""" + + name = TextParameter.TEMPERATURE class MaxTokensMapper(_MaxTokensMapper): - name = Parameter.MAX_TOKENS + """Map max_tokens to Anthropic's max_tokens parameter.""" + + name = TextParameter.MAX_TOKENS class ThinkingBudgetMapper(_ThinkingMapper): - """Translate unified thinking_budget values to Anthropic-native format.""" + """Map thinking_budget to Anthropic's thinking parameter.""" - name = TextGenerationParameter.THINKING_BUDGET + name = TextParameter.THINKING_BUDGET def map( self, @@ -54,8 +57,10 @@ def map( return super().map(request, provider_value, model) -class OutputSchemaMapper(_OutputSchemaMapper): - name = TextGenerationParameter.OUTPUT_SCHEMA +class OutputSchemaMapper(_OutputFormatMapper): + """Map output_schema to Anthropic's output_format parameter.""" + + name = TextParameter.OUTPUT_SCHEMA ANTHROPIC_PARAMETER_MAPPERS: list[ParameterMapper] = [ diff --git a/src/celeste/modalities/text/providers/cohere/__init__.py b/src/celeste/modalities/text/providers/cohere/__init__.py new file mode 100644 index 00000000..f0485c21 --- /dev/null +++ b/src/celeste/modalities/text/providers/cohere/__init__.py @@ -0,0 +1,6 @@ +"""Cohere provider for text modality.""" + +from .client import CohereTextClient +from .models import MODELS + +__all__ = ["MODELS", "CohereTextClient"] diff --git a/src/celeste/modalities/text/providers/cohere/client.py b/src/celeste/modalities/text/providers/cohere/client.py new file mode 100644 index 00000000..e7b1419b --- /dev/null +++ b/src/celeste/modalities/text/providers/cohere/client.py @@ -0,0 +1,147 @@ +"""Cohere text client (modality).""" + +from typing import Any, Unpack + +from celeste.parameters import ParameterMapper +from celeste.providers.cohere.chat.client import CohereChatClient +from celeste.providers.cohere.chat.streaming import ( + CohereChatStream as _CohereChatStream, +) +from celeste.types import ImageContent, TextContent, VideoContent +from celeste.utils import build_image_data_url + +from ...client import TextClient +from ...io import ( + TextChunk, + TextFinishReason, + TextInput, + TextOutput, + TextUsage, +) +from ...parameters import TextParameters +from ...streaming import TextStream +from .parameters import COHERE_PARAMETER_MAPPERS + + +class CohereTextStream(_CohereChatStream, TextStream): + """Cohere streaming for text modality.""" + + def _parse_chunk_usage(self, event_data: dict[str, Any]) -> TextUsage | None: + """Parse and wrap usage from SSE event.""" + usage = super()._parse_chunk_usage(event_data) + if usage: + return TextUsage(**usage) + return None + + def _parse_chunk_finish_reason( + self, event_data: dict[str, Any] + ) -> TextFinishReason | None: + """Parse and wrap finish reason from SSE event.""" + finish_reason = super()._parse_chunk_finish_reason(event_data) + if finish_reason: + return TextFinishReason(reason=finish_reason.reason) + return None + + def _parse_chunk(self, event_data: dict[str, Any]) -> TextChunk | None: + """Parse one SSE event into a typed chunk.""" + content = self._parse_chunk_content(event_data) + if content is None: + usage = self._parse_chunk_usage(event_data) + finish_reason = self._parse_chunk_finish_reason(event_data) + if usage is None and finish_reason is None: + return None + content = "" + + return TextChunk( + content=content, + finish_reason=self._parse_chunk_finish_reason(event_data), + usage=self._parse_chunk_usage(event_data), + metadata={"event_data": event_data}, + ) + + def _aggregate_content(self, chunks: list[TextChunk]) -> str: + """Aggregate streamed text content.""" + return "".join(chunk.content for chunk in chunks) + + def _aggregate_event_data(self, chunks: list[TextChunk]) -> list[dict[str, Any]]: + """Collect raw events (filtering happens in _build_stream_metadata).""" + events: list[dict[str, Any]] = [] + for chunk in chunks: + event_data = chunk.metadata.get("event_data") + if isinstance(event_data, dict): + events.append(event_data) + return events + + +class CohereTextClient(CohereChatClient, TextClient): + """Cohere text client.""" + + @classmethod + def parameter_mappers(cls) -> list[ParameterMapper]: + return COHERE_PARAMETER_MAPPERS + + async def generate( + self, + prompt: str, + **parameters: Unpack[TextParameters], + ) -> TextOutput: + """Generate text from prompt.""" + inputs = TextInput(prompt=prompt) + return await self._predict(inputs, **parameters) + + async def analyze( + self, + prompt: str, + *, + image: ImageContent | None = None, + video: VideoContent | None = None, + **parameters: Unpack[TextParameters], + ) -> TextOutput: + """Analyze image(s) or video(s) with prompt.""" + inputs = TextInput(prompt=prompt, image=image, video=video) + return await self._predict(inputs, **parameters) + + def _init_request(self, inputs: TextInput) -> dict[str, Any]: + """Initialize request from Cohere v2 Chat API messages array format.""" + if inputs.image is None: + content: str | list[dict[str, Any]] = inputs.prompt + 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}) + + return {"messages": [{"role": "user", "content": content}]} + + def _parse_usage(self, response_data: dict[str, Any]) -> TextUsage: + """Parse usage from response.""" + usage = super()._parse_usage(response_data) + return TextUsage(**usage) + + def _parse_content( + self, + response_data: dict[str, Any], + **parameters: Unpack[TextParameters], + ) -> TextContent: + """Parse content from response.""" + content_array = super()._parse_content(response_data) + first_content = content_array[0] + text = first_content.get("text") or "" + return self._transform_output(text, **parameters) + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> TextFinishReason: + """Parse finish reason from response.""" + finish_reason = super()._parse_finish_reason(response_data) + return TextFinishReason(reason=finish_reason.reason) + + def _stream_class(self) -> type[TextStream]: + """Return the Stream class for this provider.""" + return CohereTextStream + + +__all__ = ["CohereTextClient", "CohereTextStream"] diff --git a/src/celeste/modalities/text/providers/cohere/models.py b/src/celeste/modalities/text/providers/cohere/models.py new file mode 100644 index 00000000..c36b2b34 --- /dev/null +++ b/src/celeste/modalities/text/providers/cohere/models.py @@ -0,0 +1,49 @@ +"""Cohere 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="command-a-vision-07-2025", + provider=Provider.COHERE, + display_name="Command A Vision 07-2025", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, + streaming=True, + parameter_constraints={ + Parameter.TEMPERATURE: Range(min=0.0, max=1.0, step=0.01), + Parameter.MAX_TOKENS: Range(min=1, max=4096, step=1), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.IMAGE: ImagesConstraint(), + }, + ), + Model( + id="command-a-03-2025", + provider=Provider.COHERE, + display_name="Command A 03-2025", + operations={Modality.TEXT: {Operation.GENERATE}}, + streaming=True, + parameter_constraints={ + Parameter.TEMPERATURE: Range(min=0.0, max=1.0, step=0.01), + Parameter.MAX_TOKENS: Range(min=1, max=4096, step=1), + TextParameter.OUTPUT_SCHEMA: Schema(), + # thinking_budget: Not confirmed for this model, omit constraint + }, + ), + Model( + id="command-r7b-12-2024", + provider=Provider.COHERE, + display_name="Command R7B", + operations={Modality.TEXT: {Operation.GENERATE}}, + streaming=True, + parameter_constraints={ + Parameter.TEMPERATURE: Range(min=0.0, max=1.0, step=0.01), + Parameter.MAX_TOKENS: Range(min=1, max=4096, step=1), + TextParameter.OUTPUT_SCHEMA: Schema(), + # thinking_budget: Support unclear, omit constraint for now + }, + ), +] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/parameters.py b/src/celeste/modalities/text/providers/cohere/parameters.py similarity index 63% rename from packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/parameters.py rename to src/celeste/modalities/text/providers/cohere/parameters.py index 809d7dc7..726e6605 100644 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/parameters.py +++ b/src/celeste/modalities/text/providers/cohere/parameters.py @@ -1,38 +1,41 @@ -"""Cohere Chat parameter mappers for text generation.""" +"""Cohere parameter mappers for text.""" from typing import Any -from celeste_cohere.chat.parameters import ( +from celeste.models import Model +from celeste.parameters import ParameterMapper +from celeste.providers.cohere.chat.parameters import ( MaxTokensMapper as _MaxTokensMapper, ) -from celeste_cohere.chat.parameters import ( - OutputSchemaMapper as _OutputSchemaMapper, +from celeste.providers.cohere.chat.parameters import ( + ResponseFormatMapper as _ResponseFormatMapper, ) -from celeste_cohere.chat.parameters import ( +from celeste.providers.cohere.chat.parameters import ( TemperatureMapper as _TemperatureMapper, ) -from celeste_cohere.chat.parameters import ( +from celeste.providers.cohere.chat.parameters import ( ThinkingMapper as _ThinkingMapper, ) -from celeste.core import Parameter -from celeste.models import Model -from celeste.parameters import ParameterMapper -from celeste_text_generation.parameters import TextGenerationParameter +from ...parameters import TextParameter class TemperatureMapper(_TemperatureMapper): - name = Parameter.TEMPERATURE + """Map temperature to Cohere's temperature parameter.""" + + name = TextParameter.TEMPERATURE class MaxTokensMapper(_MaxTokensMapper): - name = Parameter.MAX_TOKENS + """Map max_tokens to Cohere's max_tokens parameter.""" + + name = TextParameter.MAX_TOKENS class ThinkingBudgetMapper(_ThinkingMapper): """Translate unified thinking_budget values to Cohere-native format.""" - name = TextGenerationParameter.THINKING_BUDGET + name = TextParameter.THINKING_BUDGET def map( self, @@ -56,8 +59,10 @@ def map( return super().map(request, provider_value, model) -class OutputSchemaMapper(_OutputSchemaMapper): - name = TextGenerationParameter.OUTPUT_SCHEMA +class OutputSchemaMapper(_ResponseFormatMapper): + """Map output_schema to Cohere's response_format parameter.""" + + name = TextParameter.OUTPUT_SCHEMA COHERE_PARAMETER_MAPPERS: list[ParameterMapper] = [ diff --git a/src/celeste/modalities/text/providers/deepseek/__init__.py b/src/celeste/modalities/text/providers/deepseek/__init__.py new file mode 100644 index 00000000..1f8689c9 --- /dev/null +++ b/src/celeste/modalities/text/providers/deepseek/__init__.py @@ -0,0 +1,6 @@ +"""DeepSeek provider for text modality.""" + +from .client import DeepSeekTextClient +from .models import MODELS + +__all__ = ["MODELS", "DeepSeekTextClient"] diff --git a/src/celeste/modalities/text/providers/deepseek/client.py b/src/celeste/modalities/text/providers/deepseek/client.py new file mode 100644 index 00000000..07821d09 --- /dev/null +++ b/src/celeste/modalities/text/providers/deepseek/client.py @@ -0,0 +1,128 @@ +"""DeepSeek text client (modality).""" + +from typing import Any, Unpack + +from celeste.parameters import ParameterMapper +from celeste.providers.deepseek.chat.client import DeepSeekChatClient +from celeste.providers.deepseek.chat.streaming import ( + DeepSeekChatStream as _DeepSeekChatStream, +) +from celeste.types import TextContent + +from ...client import TextClient +from ...io import ( + TextChunk, + TextFinishReason, + TextInput, + TextOutput, + TextUsage, +) +from ...parameters import TextParameters +from ...streaming import TextStream +from .parameters import DEEPSEEK_PARAMETER_MAPPERS + + +class DeepSeekTextStream(_DeepSeekChatStream, TextStream): + """DeepSeek streaming for text modality.""" + + def _parse_chunk_usage(self, event_data: dict[str, Any]) -> TextUsage | None: + """Parse and wrap usage from SSE event.""" + usage = super()._parse_chunk_usage(event_data) + if usage: + return TextUsage(**usage) + return None + + def _parse_chunk_finish_reason( + self, event_data: dict[str, Any] + ) -> TextFinishReason | None: + """Parse and wrap finish reason from SSE event.""" + finish_reason = super()._parse_chunk_finish_reason(event_data) + if finish_reason: + return TextFinishReason(reason=finish_reason.reason) + return None + + def _parse_chunk(self, event_data: dict[str, Any]) -> TextChunk | None: + """Parse one SSE event into a typed chunk.""" + content = self._parse_chunk_content(event_data) + if content is None: + usage = self._parse_chunk_usage(event_data) + finish_reason = self._parse_chunk_finish_reason(event_data) + if usage is None and finish_reason is None: + return None + content = "" + + return TextChunk( + content=content, + finish_reason=self._parse_chunk_finish_reason(event_data), + usage=self._parse_chunk_usage(event_data), + metadata={"event_data": event_data}, + ) + + def _aggregate_content(self, chunks: list[TextChunk]) -> str: + """Aggregate streamed text content.""" + return "".join(chunk.content for chunk in chunks) + + def _aggregate_event_data(self, chunks: list[TextChunk]) -> list[dict[str, Any]]: + """Collect metadata events.""" + events: list[dict[str, Any]] = [] + for chunk in chunks: + event_data = chunk.metadata.get("event_data") + if isinstance(event_data, dict): + events.append(event_data) + return events + + +class DeepSeekTextClient(DeepSeekChatClient, TextClient): + """DeepSeek text client.""" + + @classmethod + def parameter_mappers(cls) -> list[ParameterMapper]: + return DEEPSEEK_PARAMETER_MAPPERS + + async def generate( + self, + prompt: str, + **parameters: Unpack[TextParameters], + ) -> TextOutput: + """Generate text from prompt.""" + inputs = TextInput(prompt=prompt) + return await self._predict(inputs, **parameters) + + def _init_request(self, inputs: TextInput) -> dict[str, Any]: + """Initialize request from DeepSeek messages array format.""" + messages = [ + { + "role": "user", + "content": inputs.prompt, + } + ] + + return {"messages": messages} + + def _parse_usage(self, response_data: dict[str, Any]) -> TextUsage: + """Parse usage from response.""" + usage = super()._parse_usage(response_data) + return TextUsage(**usage) + + def _parse_content( + self, + response_data: dict[str, Any], + **parameters: Unpack[TextParameters], + ) -> TextContent: + """Parse 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 _parse_finish_reason(self, response_data: dict[str, Any]) -> TextFinishReason: + """Parse finish reason from response.""" + finish_reason = super()._parse_finish_reason(response_data) + return TextFinishReason(reason=finish_reason.reason) + + def _stream_class(self) -> type[TextStream]: + """Return the Stream class for this provider.""" + return DeepSeekTextStream + + +__all__ = ["DeepSeekTextClient", "DeepSeekTextStream"] diff --git a/src/celeste/modalities/text/providers/deepseek/models.py b/src/celeste/modalities/text/providers/deepseek/models.py new file mode 100644 index 00000000..e7dae886 --- /dev/null +++ b/src/celeste/modalities/text/providers/deepseek/models.py @@ -0,0 +1,34 @@ +"""DeepSeek models for text modality.""" + +from celeste.constraints import Range, Schema +from celeste.core import Modality, Operation, Parameter, Provider +from celeste.models import Model + +from ...parameters import TextParameter + +MODELS: list[Model] = [ + Model( + id="deepseek-chat", + provider=Provider.DEEPSEEK, + display_name="DeepSeek Chat", + 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=8192, step=1), + TextParameter.OUTPUT_SCHEMA: Schema(), + }, + ), + Model( + id="deepseek-reasoner", + provider=Provider.DEEPSEEK, + display_name="DeepSeek Reasoner", + 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=65536, step=1), + TextParameter.OUTPUT_SCHEMA: Schema(), + }, + ), +] diff --git a/src/celeste/modalities/text/providers/deepseek/parameters.py b/src/celeste/modalities/text/providers/deepseek/parameters.py new file mode 100644 index 00000000..02b9462b --- /dev/null +++ b/src/celeste/modalities/text/providers/deepseek/parameters.py @@ -0,0 +1,41 @@ +"""DeepSeek parameter mappers for text.""" + +from celeste.parameters import ParameterMapper +from celeste.providers.deepseek.chat.parameters import ( + MaxTokensMapper as _MaxTokensMapper, +) +from celeste.providers.deepseek.chat.parameters import ( + ResponseFormatMapper as _ResponseFormatMapper, +) +from celeste.providers.deepseek.chat.parameters import ( + TemperatureMapper as _TemperatureMapper, +) + +from ...parameters import TextParameter + + +class TemperatureMapper(_TemperatureMapper): + """Map temperature to DeepSeek's temperature parameter.""" + + name = TextParameter.TEMPERATURE + + +class MaxTokensMapper(_MaxTokensMapper): + """Map max_tokens to DeepSeek's max_tokens parameter.""" + + name = TextParameter.MAX_TOKENS + + +class OutputSchemaMapper(_ResponseFormatMapper): + """Map output_schema to DeepSeek's response_format parameter.""" + + name = TextParameter.OUTPUT_SCHEMA + + +DEEPSEEK_PARAMETER_MAPPERS: list[ParameterMapper] = [ + TemperatureMapper(), + MaxTokensMapper(), + OutputSchemaMapper(), +] + +__all__ = ["DEEPSEEK_PARAMETER_MAPPERS"] diff --git a/src/celeste/modalities/text/providers/google/__init__.py b/src/celeste/modalities/text/providers/google/__init__.py new file mode 100644 index 00000000..f0f10d2f --- /dev/null +++ b/src/celeste/modalities/text/providers/google/__init__.py @@ -0,0 +1,6 @@ +"""Google provider for text modality.""" + +from .client import GoogleTextClient +from .models import MODELS + +__all__ = ["MODELS", "GoogleTextClient"] diff --git a/src/celeste/modalities/text/providers/google/client.py b/src/celeste/modalities/text/providers/google/client.py new file mode 100644 index 00000000..e8c4dc13 --- /dev/null +++ b/src/celeste/modalities/text/providers/google/client.py @@ -0,0 +1,201 @@ +"""Google text client (modality).""" + +import base64 +from typing import Any, Unpack + +from celeste.artifacts import AudioArtifact, ImageArtifact, VideoArtifact +from celeste.parameters import ParameterMapper +from celeste.providers.google.generate_content import config as google_config +from celeste.providers.google.generate_content.client import GoogleGenerateContentClient +from celeste.providers.google.generate_content.streaming import ( + GoogleGenerateContentStream as _GoogleGenerateContentStream, +) +from celeste.types import AudioContent, ImageContent, TextContent, VideoContent +from celeste.utils import detect_mime_type + +from ...client import TextClient +from ...io import ( + TextChunk, + TextFinishReason, + TextInput, + TextOutput, + TextUsage, +) +from ...parameters import TextParameters +from ...streaming import TextStream +from .parameters import GOOGLE_PARAMETER_MAPPERS + + +class GoogleTextStream(_GoogleGenerateContentStream, TextStream): + """Google streaming for text modality.""" + + def _parse_chunk_usage(self, event_data: dict[str, Any]) -> TextUsage | None: + """Parse and wrap usage from SSE event.""" + usage = super()._parse_chunk_usage(event_data) + if usage: + return TextUsage(**usage) + return None + + def _parse_chunk_finish_reason( + self, event_data: dict[str, Any] + ) -> TextFinishReason | None: + """Parse and wrap finish reason from SSE event.""" + finish_reason = super()._parse_chunk_finish_reason(event_data) + if finish_reason: + return TextFinishReason(reason=finish_reason.reason) + return None + + def _parse_chunk(self, event_data: dict[str, Any]) -> TextChunk | None: + """Parse one SSE event into a typed chunk.""" + content = self._parse_chunk_content(event_data) + if content is None: + usage = self._parse_chunk_usage(event_data) + finish_reason = self._parse_chunk_finish_reason(event_data) + if usage is None and finish_reason is None: + return None + content = "" + + return TextChunk( + content=content, + finish_reason=self._parse_chunk_finish_reason(event_data), + usage=self._parse_chunk_usage(event_data), + metadata={"event_data": event_data}, + ) + + def _aggregate_content(self, chunks: list[TextChunk]) -> str: + """Aggregate streamed text content.""" + return "".join(chunk.content for chunk in chunks) + + def _aggregate_event_data(self, chunks: list[TextChunk]) -> list[dict[str, Any]]: + """Collect metadata events.""" + events: list[dict[str, Any]] = [] + for chunk in chunks: + event_data = chunk.metadata.get("event_data") + if isinstance(event_data, dict): + events.append(event_data) + return events + + +class GoogleTextClient(GoogleGenerateContentClient, TextClient): + """Google text client.""" + + @classmethod + def parameter_mappers(cls) -> list[ParameterMapper]: + return GOOGLE_PARAMETER_MAPPERS + + async def generate( + self, + prompt: str, + **parameters: Unpack[TextParameters], + ) -> TextOutput: + """Generate text from prompt.""" + inputs = TextInput(prompt=prompt) + return await self._predict( + inputs, + endpoint=google_config.GoogleGenerateContentEndpoint.GENERATE_CONTENT, + **parameters, + ) + + async def analyze( + self, + prompt: str, + *, + image: ImageContent | None = None, + video: VideoContent | None = None, + audio: AudioContent | None = None, + **parameters: Unpack[TextParameters], + ) -> TextOutput: + """Analyze image(s), video(s), or audio with prompt.""" + inputs = TextInput(prompt=prompt, image=image, video=video, audio=audio) + return await self._predict( + inputs, + endpoint=google_config.GoogleGenerateContentEndpoint.GENERATE_CONTENT, + **parameters, + ) + + def _init_request(self, inputs: TextInput) -> dict[str, Any]: + """Initialize request from Google contents array format.""" + parts: list[dict[str, Any]] = [] + + if inputs.image is not None: + images = inputs.image if isinstance(inputs.image, list) else [inputs.image] + for img in images: + parts.append(self._build_image_part(img)) + + if inputs.video is not None: + videos = inputs.video if isinstance(inputs.video, list) else [inputs.video] + for vid in videos: + parts.append(self._build_video_part(vid)) + + if inputs.audio is not None: + audios = inputs.audio if isinstance(inputs.audio, list) else [inputs.audio] + for aud in audios: + parts.append(self._build_audio_part(aud)) + + parts.append({"text": inputs.prompt}) + + return {"contents": [{"role": "user", "parts": parts}]} + + def _build_image_part(self, image: ImageArtifact) -> dict[str, Any]: + """Build a Gemini part from an ImageArtifact.""" + if image.url: + return {"file_data": {"file_uri": image.url}} + + image_bytes = image.get_bytes() + b64 = base64.b64encode(image_bytes).decode("utf-8") + mime = image.mime_type or detect_mime_type(image_bytes) + mime_str = mime.value if mime else None + + return {"inline_data": {"mime_type": mime_str, "data": b64}} + + def _build_video_part(self, video: VideoArtifact) -> dict[str, Any]: + """Build a Gemini part from a VideoArtifact.""" + if video.url: + return {"file_data": {"file_uri": video.url}} + + video_bytes = video.get_bytes() + b64 = base64.b64encode(video_bytes).decode("utf-8") + mime = video.mime_type or detect_mime_type(video_bytes) + mime_str = mime.value if mime else None + + return {"inline_data": {"mime_type": mime_str, "data": b64}} + + def _build_audio_part(self, audio: AudioArtifact) -> dict[str, Any]: + """Build a Gemini part from an AudioArtifact.""" + if audio.url: + return {"file_data": {"file_uri": audio.url}} + + audio_bytes = audio.get_bytes() + b64 = base64.b64encode(audio_bytes).decode("utf-8") + mime = audio.mime_type or detect_mime_type(audio_bytes) + mime_str = mime.value if mime else None + + return {"inline_data": {"mime_type": mime_str, "data": b64}} + + def _parse_usage(self, response_data: dict[str, Any]) -> TextUsage: + """Parse usage from response.""" + usage = super()._parse_usage(response_data) + return TextUsage(**usage) + + def _parse_content( + self, + response_data: dict[str, Any], + **parameters: Unpack[TextParameters], + ) -> TextContent: + """Parse content from response.""" + candidates = super()._parse_content(response_data) + parts = candidates[0].get("content", {}).get("parts", []) + text = parts[0].get("text") if parts else "" + return self._transform_output(text or "", **parameters) + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> TextFinishReason: + """Parse finish reason from response.""" + finish_reason = super()._parse_finish_reason(response_data) + return TextFinishReason(reason=finish_reason.reason) + + def _stream_class(self) -> type[TextStream]: + """Return the Stream class for this provider.""" + return GoogleTextStream + + +__all__ = ["GoogleTextClient", "GoogleTextStream"] diff --git a/src/celeste/modalities/text/providers/google/models.py b/src/celeste/modalities/text/providers/google/models.py new file mode 100644 index 00000000..ce5f718e --- /dev/null +++ b/src/celeste/modalities/text/providers/google/models.py @@ -0,0 +1,115 @@ +"""Google models for text modality.""" + +from celeste.constraints import ( + AudioConstraint, + Bool, + Choice, + ImagesConstraint, + Range, + Schema, + VideosConstraint, +) +from celeste.core import Modality, Operation, Parameter, Provider +from celeste.models import Model + +from ...parameters import TextParameter + +MODELS: list[Model] = [ + Model( + id="gemini-2.5-flash", + provider=Provider.GOOGLE, + display_name="Gemini 2.5 Flash", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, + streaming=True, + parameter_constraints={ + Parameter.TEMPERATURE: Range(min=0.0, max=2.0), + Parameter.MAX_TOKENS: Range(min=1, max=65536), + # Flash: allows -1 (dynamic), 0 (disable), or >= 0 + TextParameter.THINKING_BUDGET: Range(min=-1, max=24576), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.WEB_SEARCH: Bool(), + # Media input support + TextParameter.IMAGE: ImagesConstraint(), + TextParameter.VIDEO: VideosConstraint(), + TextParameter.AUDIO: AudioConstraint(), + }, + ), + Model( + id="gemini-2.5-flash-lite", + provider=Provider.GOOGLE, + display_name="Gemini 2.5 Flash Lite", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, + streaming=True, + parameter_constraints={ + Parameter.TEMPERATURE: Range(min=0.0, max=2.0), + Parameter.MAX_TOKENS: Range(min=1, max=65536), + # Flash Lite: allows -1 (dynamic), 0 (disable), or >= 512 + TextParameter.THINKING_BUDGET: Range( + min=512, max=24576, special_values=[-1, 0] + ), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.WEB_SEARCH: Bool(), + # Media input support + TextParameter.IMAGE: ImagesConstraint(), + TextParameter.VIDEO: VideosConstraint(), + TextParameter.AUDIO: AudioConstraint(), + }, + ), + Model( + id="gemini-2.5-pro", + provider=Provider.GOOGLE, + display_name="Gemini 2.5 Pro", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, + streaming=True, + parameter_constraints={ + Parameter.TEMPERATURE: Range(min=0.0, max=2.0), + Parameter.MAX_TOKENS: Range(min=1, max=65536), + # Pro: allows -1 (dynamic) or >= 128 (cannot use 0) + TextParameter.THINKING_BUDGET: Range( + min=128, max=32768, special_values=[-1] + ), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.WEB_SEARCH: Bool(), + # Media input support + TextParameter.IMAGE: ImagesConstraint(), + TextParameter.VIDEO: VideosConstraint(), + TextParameter.AUDIO: AudioConstraint(), + }, + ), + Model( + id="gemini-3-pro-preview", + provider=Provider.GOOGLE, + display_name="Gemini 3 Pro", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, + streaming=True, + parameter_constraints={ + Parameter.TEMPERATURE: Range(min=0.0, max=2.0), + Parameter.MAX_TOKENS: Range(min=1, max=65536), + TextParameter.THINKING_LEVEL: Choice(options=["low", "high"]), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.WEB_SEARCH: Bool(), + # Media input support + TextParameter.IMAGE: ImagesConstraint(), + TextParameter.VIDEO: VideosConstraint(), + TextParameter.AUDIO: AudioConstraint(), + }, + ), + Model( + id="gemini-3-flash-preview", + provider=Provider.GOOGLE, + display_name="Gemini 3 Flash", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, + streaming=True, + parameter_constraints={ + Parameter.TEMPERATURE: Range(min=0.0, max=2.0), + Parameter.MAX_TOKENS: Range(min=1, max=65536), + TextParameter.THINKING_LEVEL: Choice(options=["low", "high"]), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.WEB_SEARCH: Bool(), + # Media input support + TextParameter.IMAGE: ImagesConstraint(), + TextParameter.VIDEO: VideosConstraint(), + TextParameter.AUDIO: AudioConstraint(), + }, + ), +] diff --git a/src/celeste/modalities/text/providers/google/parameters.py b/src/celeste/modalities/text/providers/google/parameters.py new file mode 100644 index 00000000..2a61e808 --- /dev/null +++ b/src/celeste/modalities/text/providers/google/parameters.py @@ -0,0 +1,71 @@ +"""Google parameter mappers for text.""" + +from celeste.parameters import ParameterMapper +from celeste.providers.google.generate_content.parameters import ( + MaxOutputTokensMapper as _MaxOutputTokensMapper, +) +from celeste.providers.google.generate_content.parameters import ( + ResponseJsonSchemaMapper as _ResponseJsonSchemaMapper, +) +from celeste.providers.google.generate_content.parameters import ( + TemperatureMapper as _TemperatureMapper, +) +from celeste.providers.google.generate_content.parameters import ( + ThinkingBudgetMapper as _ThinkingBudgetMapper, +) +from celeste.providers.google.generate_content.parameters import ( + ThinkingLevelMapper as _ThinkingLevelMapper, +) +from celeste.providers.google.generate_content.parameters import ( + WebSearchMapper as _WebSearchMapper, +) + +from ...parameters import TextParameter + + +class TemperatureMapper(_TemperatureMapper): + """Map temperature to Google's generationConfig.temperature parameter.""" + + name = TextParameter.TEMPERATURE + + +class MaxTokensMapper(_MaxOutputTokensMapper): + """Map max_tokens to Google's generationConfig.maxOutputTokens parameter.""" + + name = TextParameter.MAX_TOKENS + + +class ThinkingBudgetMapper(_ThinkingBudgetMapper): + """Map thinking_budget to Google's generationConfig.thinkingConfig.thinkingBudget parameter.""" + + name = TextParameter.THINKING_BUDGET + + +class ThinkingLevelMapper(_ThinkingLevelMapper): + """Map thinking_level to Google's generationConfig.thinkingConfig.thinkingLevel parameter.""" + + name = TextParameter.THINKING_LEVEL + + +class OutputSchemaMapper(_ResponseJsonSchemaMapper): + """Map output_schema to Google's generationConfig.responseJsonSchema parameter.""" + + name = TextParameter.OUTPUT_SCHEMA + + +class WebSearchMapper(_WebSearchMapper): + """Map web_search to Google's tools parameter.""" + + name = TextParameter.WEB_SEARCH + + +GOOGLE_PARAMETER_MAPPERS: list[ParameterMapper] = [ + TemperatureMapper(), + MaxTokensMapper(), + ThinkingBudgetMapper(), + ThinkingLevelMapper(), + OutputSchemaMapper(), + WebSearchMapper(), +] + +__all__ = ["GOOGLE_PARAMETER_MAPPERS"] diff --git a/src/celeste/modalities/text/providers/groq/__init__.py b/src/celeste/modalities/text/providers/groq/__init__.py new file mode 100644 index 00000000..dd8ddb5f --- /dev/null +++ b/src/celeste/modalities/text/providers/groq/__init__.py @@ -0,0 +1,6 @@ +"""Groq provider for text modality.""" + +from .client import GroqTextClient +from .models import MODELS + +__all__ = ["MODELS", "GroqTextClient"] diff --git a/src/celeste/modalities/text/providers/groq/client.py b/src/celeste/modalities/text/providers/groq/client.py new file mode 100644 index 00000000..c50dc39e --- /dev/null +++ b/src/celeste/modalities/text/providers/groq/client.py @@ -0,0 +1,145 @@ +"""Groq text client (modality).""" + +from typing import Any, Unpack + +from celeste.parameters import ParameterMapper +from celeste.providers.groq.chat.client import GroqChatClient +from celeste.providers.groq.chat.streaming import GroqChatStream as _GroqChatStream +from celeste.types import ImageContent, TextContent, VideoContent +from celeste.utils import build_image_data_url + +from ...client import TextClient +from ...io import ( + TextChunk, + TextFinishReason, + TextInput, + TextOutput, + TextUsage, +) +from ...parameters import TextParameters +from ...streaming import TextStream +from .parameters import GROQ_PARAMETER_MAPPERS + + +class GroqTextStream(_GroqChatStream, TextStream): + """Groq streaming for text modality.""" + + def _parse_chunk_usage(self, event_data: dict[str, Any]) -> TextUsage | None: + """Parse and wrap usage from SSE event.""" + usage = super()._parse_chunk_usage(event_data) + if usage: + return TextUsage(**usage) + return None + + def _parse_chunk_finish_reason( + self, event_data: dict[str, Any] + ) -> TextFinishReason | None: + """Parse and wrap finish reason from SSE event.""" + finish_reason = super()._parse_chunk_finish_reason(event_data) + if finish_reason: + return TextFinishReason(reason=finish_reason.reason) + return None + + def _parse_chunk(self, event_data: dict[str, Any]) -> TextChunk | None: + """Parse one SSE event into a typed chunk.""" + content = self._parse_chunk_content(event_data) + if content is None: + usage = self._parse_chunk_usage(event_data) + finish_reason = self._parse_chunk_finish_reason(event_data) + if usage is None and finish_reason is None: + return None + content = "" + + return TextChunk( + content=content, + finish_reason=self._parse_chunk_finish_reason(event_data), + usage=self._parse_chunk_usage(event_data), + metadata={"event_data": event_data}, + ) + + def _aggregate_content(self, chunks: list[TextChunk]) -> str: + """Aggregate streamed text content.""" + return "".join(chunk.content for chunk in chunks) + + def _aggregate_event_data(self, chunks: list[TextChunk]) -> list[dict[str, Any]]: + """Collect metadata events.""" + events: list[dict[str, Any]] = [] + for chunk in chunks: + event_data = chunk.metadata.get("event_data") + if isinstance(event_data, dict): + events.append(event_data) + return events + + +class GroqTextClient(GroqChatClient, TextClient): + """Groq text client.""" + + @classmethod + def parameter_mappers(cls) -> list[ParameterMapper]: + return GROQ_PARAMETER_MAPPERS + + async def generate( + self, + prompt: str, + **parameters: Unpack[TextParameters], + ) -> TextOutput: + """Generate text from prompt.""" + inputs = TextInput(prompt=prompt) + return await self._predict(inputs, **parameters) + + async def analyze( + self, + prompt: str, + *, + image: ImageContent | None = None, + video: VideoContent | None = None, + **parameters: Unpack[TextParameters], + ) -> TextOutput: + """Analyze image(s) or video(s) with prompt.""" + inputs = TextInput(prompt=prompt, image=image, video=video) + return await self._predict(inputs, **parameters) + + def _init_request(self, inputs: TextInput) -> dict[str, Any]: + """Initialize request from Groq messages array format.""" + if inputs.image is None: + content: str | list[dict[str, Any]] = inputs.prompt + 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}) + + return {"messages": [{"role": "user", "content": content}]} + + def _parse_usage(self, response_data: dict[str, Any]) -> TextUsage: + """Parse usage from response.""" + usage = super()._parse_usage(response_data) + return TextUsage(**usage) + + def _parse_content( + self, + response_data: dict[str, Any], + **parameters: Unpack[TextParameters], + ) -> TextContent: + """Parse 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 _parse_finish_reason(self, response_data: dict[str, Any]) -> TextFinishReason: + """Parse finish reason from response.""" + finish_reason = super()._parse_finish_reason(response_data) + return TextFinishReason(reason=finish_reason.reason) + + def _stream_class(self) -> type[TextStream]: + """Return the Stream class for this provider.""" + return GroqTextStream + + +__all__ = ["GroqTextClient", "GroqTextStream"] diff --git a/src/celeste/modalities/text/providers/groq/models.py b/src/celeste/modalities/text/providers/groq/models.py new file mode 100644 index 00000000..5b1c42f7 --- /dev/null +++ b/src/celeste/modalities/text/providers/groq/models.py @@ -0,0 +1,168 @@ +"""Groq 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="llama-3.3-70b-versatile", + provider=Provider.GROQ, + display_name="Llama 3.3 70B Versatile", + 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="llama-3.1-8b-instant", + provider=Provider.GROQ, + display_name="Llama 3.1 8B Instant", + 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=131072, step=1), + TextParameter.OUTPUT_SCHEMA: Schema(), + }, + ), + Model( + id="qwen/qwen3-32b", + provider=Provider.GROQ, + display_name="Qwen 3 32B", + 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=40960, step=1), + TextParameter.OUTPUT_SCHEMA: Schema(), + }, + ), + Model( + id="moonshotai/kimi-k2-instruct", + provider=Provider.GROQ, + display_name="Kimi K2 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=16384, step=1), + TextParameter.OUTPUT_SCHEMA: Schema(), + }, + ), + Model( + id="moonshotai/kimi-k2-instruct-0905", + provider=Provider.GROQ, + display_name="Kimi K2 Instruct 0905", + 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=16384, step=1), + TextParameter.OUTPUT_SCHEMA: Schema(), + }, + ), + Model( + id="meta-llama/llama-4-scout-17b-16e-instruct", + provider=Provider.GROQ, + display_name="Llama 4 Scout 17B", + 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=8192, step=1), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.IMAGE: ImagesConstraint(), + }, + ), + Model( + id="meta-llama/llama-4-maverick-17b-128e-instruct", + provider=Provider.GROQ, + display_name="Llama 4 Maverick 17B", + 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=8192, step=1), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.IMAGE: ImagesConstraint(), + }, + ), + Model( + id="openai/gpt-oss-20b", + provider=Provider.GROQ, + display_name="GPT OSS 20B", + 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=65536, step=1), + TextParameter.OUTPUT_SCHEMA: Schema(), + }, + ), + Model( + id="openai/gpt-oss-120b", + provider=Provider.GROQ, + display_name="GPT OSS 120B", + 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=65536, step=1), + TextParameter.OUTPUT_SCHEMA: Schema(), + }, + ), + Model( + id="openai/gpt-oss-safeguard-20b", + provider=Provider.GROQ, + display_name="GPT OSS Safeguard 20B", + 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=65536, step=1), + TextParameter.OUTPUT_SCHEMA: Schema(), + }, + ), + Model( + id="groq/compound", + provider=Provider.GROQ, + display_name="Groq Compound", + 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=8192, step=1), + TextParameter.OUTPUT_SCHEMA: Schema(), + }, + ), + Model( + id="groq/compound-mini", + provider=Provider.GROQ, + display_name="Groq Compound Mini", + 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=8192, step=1), + TextParameter.OUTPUT_SCHEMA: Schema(), + }, + ), + Model( + id="allam-2-7b", + provider=Provider.GROQ, + display_name="Allam 2 7B", + 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=4096, step=1), + TextParameter.OUTPUT_SCHEMA: Schema(), + }, + ), +] diff --git a/src/celeste/modalities/text/providers/groq/parameters.py b/src/celeste/modalities/text/providers/groq/parameters.py new file mode 100644 index 00000000..cf38ba2c --- /dev/null +++ b/src/celeste/modalities/text/providers/groq/parameters.py @@ -0,0 +1,41 @@ +"""Groq parameter mappers for text.""" + +from celeste.parameters import ParameterMapper +from celeste.providers.groq.chat.parameters import ( + MaxTokensMapper as _MaxTokensMapper, +) +from celeste.providers.groq.chat.parameters import ( + ResponseFormatMapper as _ResponseFormatMapper, +) +from celeste.providers.groq.chat.parameters import ( + TemperatureMapper as _TemperatureMapper, +) + +from ...parameters import TextParameter + + +class TemperatureMapper(_TemperatureMapper): + """Map temperature to Groq's temperature parameter.""" + + name = TextParameter.TEMPERATURE + + +class MaxTokensMapper(_MaxTokensMapper): + """Map max_tokens to Groq's max_tokens parameter.""" + + name = TextParameter.MAX_TOKENS + + +class OutputSchemaMapper(_ResponseFormatMapper): + """Map output_schema to Groq's response_format parameter.""" + + name = TextParameter.OUTPUT_SCHEMA + + +GROQ_PARAMETER_MAPPERS: list[ParameterMapper] = [ + TemperatureMapper(), + MaxTokensMapper(), + OutputSchemaMapper(), +] + +__all__ = ["GROQ_PARAMETER_MAPPERS"] diff --git a/src/celeste/modalities/text/providers/mistral/__init__.py b/src/celeste/modalities/text/providers/mistral/__init__.py new file mode 100644 index 00000000..6df42172 --- /dev/null +++ b/src/celeste/modalities/text/providers/mistral/__init__.py @@ -0,0 +1,6 @@ +"""Mistral provider for text modality.""" + +from .client import MistralTextClient +from .models import MODELS + +__all__ = ["MODELS", "MistralTextClient"] diff --git a/src/celeste/modalities/text/providers/mistral/client.py b/src/celeste/modalities/text/providers/mistral/client.py new file mode 100644 index 00000000..a1b47154 --- /dev/null +++ b/src/celeste/modalities/text/providers/mistral/client.py @@ -0,0 +1,154 @@ +"""Mistral text client (modality).""" + +from typing import Any, Unpack + +from celeste.parameters import ParameterMapper +from celeste.providers.mistral.chat.client import MistralChatClient +from celeste.providers.mistral.chat.streaming import ( + MistralChatStream as _MistralChatStream, +) +from celeste.types import ImageContent, TextContent, VideoContent +from celeste.utils import build_image_data_url + +from ...client import TextClient +from ...io import ( + TextChunk, + TextFinishReason, + TextInput, + TextOutput, + TextUsage, +) +from ...parameters import TextParameters +from ...streaming import TextStream +from .parameters import MISTRAL_PARAMETER_MAPPERS + + +class MistralTextStream(_MistralChatStream, TextStream): + """Mistral streaming for text modality.""" + + def _parse_chunk_usage(self, event_data: dict[str, Any]) -> TextUsage | None: + """Parse and wrap usage from SSE event.""" + usage = super()._parse_chunk_usage(event_data) + if usage: + return TextUsage(**usage) + return None + + def _parse_chunk_finish_reason( + self, event_data: dict[str, Any] + ) -> TextFinishReason | None: + """Parse and wrap finish reason from SSE event.""" + finish_reason = super()._parse_chunk_finish_reason(event_data) + if finish_reason: + return TextFinishReason(reason=finish_reason.reason) + return None + + def _parse_chunk(self, event_data: dict[str, Any]) -> TextChunk | None: + """Parse one SSE event into a typed chunk.""" + content = self._parse_chunk_content(event_data) + if content is None: + usage = self._parse_chunk_usage(event_data) + finish_reason = self._parse_chunk_finish_reason(event_data) + if usage is None and finish_reason is None: + return None + content = "" + + return TextChunk( + content=content, + finish_reason=self._parse_chunk_finish_reason(event_data), + usage=self._parse_chunk_usage(event_data), + metadata={"event_data": event_data}, + ) + + def _aggregate_content(self, chunks: list[TextChunk]) -> str: + """Aggregate streamed text content.""" + return "".join(chunk.content for chunk in chunks) + + def _aggregate_event_data(self, chunks: list[TextChunk]) -> list[dict[str, Any]]: + """Collect metadata events.""" + events: list[dict[str, Any]] = [] + for chunk in chunks: + event_data = chunk.metadata.get("event_data") + if isinstance(event_data, dict): + events.append(event_data) + return events + + +class MistralTextClient(MistralChatClient, TextClient): + """Mistral text client.""" + + @classmethod + def parameter_mappers(cls) -> list[ParameterMapper]: + return MISTRAL_PARAMETER_MAPPERS + + async def generate( + self, + prompt: str, + **parameters: Unpack[TextParameters], + ) -> TextOutput: + """Generate text from prompt.""" + inputs = TextInput(prompt=prompt) + return await self._predict(inputs, **parameters) + + async def analyze( + self, + prompt: str, + *, + image: ImageContent | None = None, + video: VideoContent | None = None, + **parameters: Unpack[TextParameters], + ) -> TextOutput: + """Analyze image(s) or video(s) with prompt.""" + inputs = TextInput(prompt=prompt, image=image, video=video) + return await self._predict(inputs, **parameters) + + def _init_request(self, inputs: TextInput) -> dict[str, Any]: + """Initialize request from Mistral messages array format.""" + if inputs.image is None: + content: str | list[dict[str, Any]] = inputs.prompt + 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}) + + return {"messages": [{"role": "user", "content": content}]} + + def _parse_usage(self, response_data: dict[str, Any]) -> TextUsage: + """Parse usage from response.""" + usage = super()._parse_usage(response_data) + return TextUsage(**usage) + + def _parse_content( + self, + response_data: dict[str, Any], + **parameters: Unpack[TextParameters], + ) -> TextContent: + """Parse content from response.""" + choices = super()._parse_content(response_data) + first_choice = choices[0] + message = first_choice.get("message", {}) + content = message.get("content") or "" + + # Handle magistral thinking models that return list content + if isinstance(content, list): + text_parts = [] + for block in content: + if isinstance(block, dict) and block.get("type") == "text": + text_parts.append(block.get("text", "")) + content = "".join(text_parts) + + return self._transform_output(content, **parameters) + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> TextFinishReason: + """Parse finish reason from response.""" + finish_reason = super()._parse_finish_reason(response_data) + return TextFinishReason(reason=finish_reason.reason) + + def _stream_class(self) -> type[TextStream]: + """Return the Stream class for this provider.""" + return MistralTextStream + + +__all__ = ["MistralTextClient", "MistralTextStream"] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/mistral/models.py b/src/celeste/modalities/text/providers/mistral/models.py similarity index 60% rename from packages/capabilities/text-generation/src/celeste_text_generation/providers/mistral/models.py rename to src/celeste/modalities/text/providers/mistral/models.py index 91b557d9..114b5ecd 100644 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/mistral/models.py +++ b/src/celeste/modalities/text/providers/mistral/models.py @@ -1,187 +1,235 @@ -"""Mistral models for text generation.""" +"""Mistral models for text modality.""" -from celeste import Model, Provider -from celeste.constraints import Range, Schema -from celeste.core import Parameter -from celeste_text_generation.parameters import TextGenerationParameter +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="mistral-large-latest", provider=Provider.MISTRAL, display_name="Mistral Large", + 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), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.OUTPUT_SCHEMA: Schema(), }, ), Model( id="mistral-medium-latest", provider=Provider.MISTRAL, display_name="Mistral Medium", + 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), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.IMAGE: ImagesConstraint(), }, ), Model( id="mistral-small-latest", provider=Provider.MISTRAL, display_name="Mistral Small", + 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), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.IMAGE: ImagesConstraint(), }, ), Model( id="mistral-tiny", provider=Provider.MISTRAL, display_name="Mistral Tiny", + 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), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.OUTPUT_SCHEMA: Schema(), }, ), Model( id="open-mistral-7b", provider=Provider.MISTRAL, display_name="Open Mistral 7B", + 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), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.OUTPUT_SCHEMA: Schema(), }, ), Model( id="codestral-latest", provider=Provider.MISTRAL, display_name="Codestral", + 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), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.OUTPUT_SCHEMA: Schema(), }, ), Model( id="devstral-medium-latest", provider=Provider.MISTRAL, display_name="Devstral Medium", + 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), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.OUTPUT_SCHEMA: Schema(), }, ), Model( id="devstral-2512", provider=Provider.MISTRAL, display_name="Devstral 2", + 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), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.OUTPUT_SCHEMA: Schema(), }, ), Model( id="labs-devstral-small-2512", provider=Provider.MISTRAL, display_name="Devstral 2 Small", + 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), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.OUTPUT_SCHEMA: Schema(), }, ), Model( id="pixtral-12b-2409", provider=Provider.MISTRAL, display_name="Pixtral 12B", + 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(), + }, + ), + Model( + id="pixtral-12b-latest", + provider=Provider.MISTRAL, + display_name="Pixtral 12B", + 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(), + }, + ), + Model( + id="pixtral-large-latest", + provider=Provider.MISTRAL, + display_name="Pixtral Large", + 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), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.IMAGE: ImagesConstraint(), }, ), Model( id="ministral-3b-latest", provider=Provider.MISTRAL, display_name="Ministral 3B", + 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), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.OUTPUT_SCHEMA: Schema(), }, ), Model( id="ministral-8b-latest", provider=Provider.MISTRAL, display_name="Ministral 8B", + 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), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.OUTPUT_SCHEMA: Schema(), }, ), Model( id="ministral-14b-latest", provider=Provider.MISTRAL, display_name="Ministral 14B", + 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), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.OUTPUT_SCHEMA: Schema(), }, ), Model( id="voxtral-mini-2507", provider=Provider.MISTRAL, display_name="Voxtral Mini", + 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), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.OUTPUT_SCHEMA: Schema(), }, ), Model( id="magistral-small-latest", provider=Provider.MISTRAL, display_name="Magistral Small", + 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), - TextGenerationParameter.THINKING_BUDGET: Range(min=-1, max=32768, step=1), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.THINKING_BUDGET: Range(min=-1, max=32768, step=1), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.IMAGE: ImagesConstraint(), }, ), Model( id="magistral-medium-latest", provider=Provider.MISTRAL, display_name="Magistral Medium", + 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), - TextGenerationParameter.THINKING_BUDGET: Range(min=-1, max=32768, step=1), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.THINKING_BUDGET: Range(min=-1, max=32768, step=1), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.IMAGE: ImagesConstraint(), }, ), ] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/mistral/parameters.py b/src/celeste/modalities/text/providers/mistral/parameters.py similarity index 59% rename from packages/capabilities/text-generation/src/celeste_text_generation/providers/mistral/parameters.py rename to src/celeste/modalities/text/providers/mistral/parameters.py index 79776f3c..9c644b0f 100644 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/mistral/parameters.py +++ b/src/celeste/modalities/text/providers/mistral/parameters.py @@ -1,39 +1,38 @@ -"""Mistral Chat parameter mappers for text generation.""" +"""Mistral parameter mappers for text.""" from typing import Any -from celeste_mistral.chat.parameters import ( +from celeste.models import Model +from celeste.parameters import ParameterMapper +from celeste.providers.mistral.chat.parameters import ( MaxTokensMapper as _MaxTokensMapper, ) -from celeste_mistral.chat.parameters import ( - OutputSchemaMapper as _OutputSchemaMapper, +from celeste.providers.mistral.chat.parameters import ( + ResponseFormatMapper as _ResponseFormatMapper, ) -from celeste_mistral.chat.parameters import ( +from celeste.providers.mistral.chat.parameters import ( TemperatureMapper as _TemperatureMapper, ) -from celeste.core import Parameter -from celeste.models import Model -from celeste.parameters import ParameterMapper -from celeste_text_generation.parameters import TextGenerationParameter +from ...parameters import TextParameter class TemperatureMapper(_TemperatureMapper): - name = Parameter.TEMPERATURE + """Map temperature to Mistral's temperature parameter.""" + + name = TextParameter.TEMPERATURE class MaxTokensMapper(_MaxTokensMapper): - name = Parameter.MAX_TOKENS + """Map max_tokens to Mistral's max_tokens parameter.""" + name = TextParameter.MAX_TOKENS -class ThinkingBudgetMapper(ParameterMapper): - """Map thinking_budget to Mistral prompt_mode (Pattern 3: Coercion). - Mistral uses prompt_mode instead of a thinking parameter, so this creates - a unified parameter that maps to the provider-specific format. - """ +class ThinkingBudgetMapper(ParameterMapper): + """Map thinking_budget to Mistral's prompt_mode parameter.""" - name = TextGenerationParameter.THINKING_BUDGET + name = TextParameter.THINKING_BUDGET def map( self, @@ -42,7 +41,6 @@ def map( model: Model, ) -> dict[str, Any]: """Transform thinking_budget into provider request.""" - validated_value = self._validate_value(value, model) if validated_value is None: return request @@ -58,8 +56,10 @@ def map( return request -class OutputSchemaMapper(_OutputSchemaMapper): - name = TextGenerationParameter.OUTPUT_SCHEMA +class OutputSchemaMapper(_ResponseFormatMapper): + """Map output_schema to Mistral's response_format parameter.""" + + name = TextParameter.OUTPUT_SCHEMA MISTRAL_PARAMETER_MAPPERS: list[ParameterMapper] = [ diff --git a/src/celeste/modalities/text/providers/moonshot/__init__.py b/src/celeste/modalities/text/providers/moonshot/__init__.py new file mode 100644 index 00000000..fceae37d --- /dev/null +++ b/src/celeste/modalities/text/providers/moonshot/__init__.py @@ -0,0 +1,6 @@ +"""Moonshot provider for text modality.""" + +from .client import MoonshotTextClient +from .models import MODELS + +__all__ = ["MODELS", "MoonshotTextClient"] diff --git a/src/celeste/modalities/text/providers/moonshot/client.py b/src/celeste/modalities/text/providers/moonshot/client.py new file mode 100644 index 00000000..0a1b447f --- /dev/null +++ b/src/celeste/modalities/text/providers/moonshot/client.py @@ -0,0 +1,144 @@ +"""Moonshot text client (modality).""" + +from typing import Any, Unpack + +from celeste.parameters import ParameterMapper +from celeste.providers.moonshot.chat.client import MoonshotChatClient +from celeste.providers.moonshot.chat.streaming import ( + MoonshotChatStream as _MoonshotChatStream, +) +from celeste.types import ImageContent, TextContent, VideoContent +from celeste.utils import build_image_data_url + +from ...client import TextClient +from ...io import ( + TextChunk, + TextFinishReason, + TextInput, + TextOutput, + TextUsage, +) +from ...parameters import TextParameters +from ...streaming import TextStream +from .parameters import MOONSHOT_PARAMETER_MAPPERS + + +class MoonshotTextStream(_MoonshotChatStream, TextStream): + """Moonshot streaming for text modality.""" + + def _parse_chunk_usage(self, event_data: dict[str, Any]) -> TextUsage | None: + """Parse and wrap usage from SSE event.""" + usage = super()._parse_chunk_usage(event_data) + if usage: + return TextUsage(**usage) + return None + + def _parse_chunk_finish_reason( + self, event_data: dict[str, Any] + ) -> TextFinishReason | None: + """Parse and wrap finish reason from SSE event.""" + finish_reason = super()._parse_chunk_finish_reason(event_data) + if finish_reason: + return TextFinishReason(reason=finish_reason.reason) + return None + + def _parse_chunk(self, event_data: dict[str, Any]) -> TextChunk | None: + """Parse one SSE event into a typed chunk.""" + content = self._parse_chunk_content(event_data) + if content is None: + usage = self._parse_chunk_usage(event_data) + finish_reason = self._parse_chunk_finish_reason(event_data) + if usage is None and finish_reason is None: + return None + content = "" + + return TextChunk( + content=content, + finish_reason=self._parse_chunk_finish_reason(event_data), + usage=self._parse_chunk_usage(event_data), + metadata={"event_data": event_data}, + ) + + def _aggregate_content(self, chunks: list[TextChunk]) -> str: + """Aggregate streamed text content.""" + return "".join(chunk.content for chunk in chunks) + + def _aggregate_event_data(self, chunks: list[TextChunk]) -> list[dict[str, Any]]: + """Collect metadata events.""" + events: list[dict[str, Any]] = [] + for chunk in chunks: + event_data = chunk.metadata.get("event_data") + if isinstance(event_data, dict): + events.append(event_data) + return events + + +class MoonshotTextClient(MoonshotChatClient, TextClient): + """Moonshot text client.""" + + @classmethod + def parameter_mappers(cls) -> list[ParameterMapper]: + return MOONSHOT_PARAMETER_MAPPERS + + async def generate( + self, + prompt: str, + **parameters: Unpack[TextParameters], + ) -> TextOutput: + """Generate text from prompt.""" + inputs = TextInput(prompt=prompt) + return await self._predict(inputs, **parameters) + + async def analyze( + self, + prompt: str, + *, + image: ImageContent | None = None, + video: VideoContent | None = None, + **parameters: Unpack[TextParameters], + ) -> TextOutput: + """Analyze image(s) or video(s) with prompt.""" + inputs = TextInput(prompt=prompt, image=image, video=video) + return await self._predict(inputs, **parameters) + + def _init_request(self, inputs: TextInput) -> dict[str, Any]: + """Initialize request from Moonshot messages array format.""" + if inputs.image is None: + content: str | list[dict[str, Any]] = inputs.prompt + 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}) + + return {"messages": [{"role": "user", "content": content}]} + + def _parse_usage(self, response_data: dict[str, Any]) -> TextUsage: + """Parse usage from response.""" + usage = super()._parse_usage(response_data) + return TextUsage(**usage) + + def _parse_content( + self, + response_data: dict[str, Any], + **parameters: Unpack[TextParameters], + ) -> TextContent: + """Parse 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 _parse_finish_reason(self, response_data: dict[str, Any]) -> TextFinishReason: + """Parse finish reason from response.""" + finish_reason = super()._parse_finish_reason(response_data) + return TextFinishReason(reason=finish_reason.reason) + + def _stream_class(self) -> type[TextStream]: + """Return the Stream class for this provider.""" + return MoonshotTextStream + + +__all__ = ["MoonshotTextClient", "MoonshotTextStream"] diff --git a/src/celeste/modalities/text/providers/moonshot/models.py b/src/celeste/modalities/text/providers/moonshot/models.py new file mode 100644 index 00000000..fa1d8c2d --- /dev/null +++ b/src/celeste/modalities/text/providers/moonshot/models.py @@ -0,0 +1,83 @@ +"""Moonshot 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="moonshot-v1-8k-vision-preview", + provider=Provider.MOONSHOT, + display_name="Moonshot v1 8K Vision (Preview)", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, + streaming=True, + parameter_constraints={ + Parameter.TEMPERATURE: Range(min=0.0, max=1.0, step=0.01), + Parameter.MAX_TOKENS: Range(min=1, max=8192, step=1), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.IMAGE: ImagesConstraint(), + }, + ), + Model( + id="kimi-k2-0905-preview", + provider=Provider.MOONSHOT, + display_name="Kimi K2 (0905 Preview)", + operations={Modality.TEXT: {Operation.GENERATE}}, + streaming=True, + parameter_constraints={ + Parameter.TEMPERATURE: Range(min=0.0, max=1.0, step=0.01), + Parameter.MAX_TOKENS: Range(min=1, max=32768, step=1), + TextParameter.OUTPUT_SCHEMA: Schema(), + }, + ), + Model( + id="kimi-k2-0711-preview", + provider=Provider.MOONSHOT, + display_name="Kimi K2 (0711 Preview)", + operations={Modality.TEXT: {Operation.GENERATE}}, + streaming=True, + parameter_constraints={ + Parameter.TEMPERATURE: Range(min=0.0, max=1.0, step=0.01), + Parameter.MAX_TOKENS: Range(min=1, max=32768, step=1), + TextParameter.OUTPUT_SCHEMA: Schema(), + }, + ), + Model( + id="kimi-k2-turbo-preview", + provider=Provider.MOONSHOT, + display_name="Kimi K2 Turbo Preview", + operations={Modality.TEXT: {Operation.GENERATE}}, + streaming=True, + parameter_constraints={ + Parameter.TEMPERATURE: Range(min=0.0, max=1.0, step=0.01), + Parameter.MAX_TOKENS: Range(min=1, max=32768, step=1), + TextParameter.OUTPUT_SCHEMA: Schema(), + }, + ), + Model( + id="kimi-k2-thinking-turbo", + provider=Provider.MOONSHOT, + display_name="Kimi K2 Thinking Turbo", + operations={Modality.TEXT: {Operation.GENERATE}}, + streaming=True, + parameter_constraints={ + Parameter.TEMPERATURE: Range(min=0.0, max=1.0, step=0.01), + Parameter.MAX_TOKENS: Range(min=1, max=32768, step=1), + TextParameter.OUTPUT_SCHEMA: Schema(), + }, + ), + Model( + id="kimi-k2-thinking", + provider=Provider.MOONSHOT, + display_name="Kimi K2 Thinking", + operations={Modality.TEXT: {Operation.GENERATE}}, + streaming=True, + parameter_constraints={ + Parameter.TEMPERATURE: Range(min=0.0, max=1.0, step=0.01), + Parameter.MAX_TOKENS: Range(min=1, max=32768, step=1), + TextParameter.OUTPUT_SCHEMA: Schema(), + }, + ), +] diff --git a/src/celeste/modalities/text/providers/moonshot/parameters.py b/src/celeste/modalities/text/providers/moonshot/parameters.py new file mode 100644 index 00000000..1889cff2 --- /dev/null +++ b/src/celeste/modalities/text/providers/moonshot/parameters.py @@ -0,0 +1,41 @@ +"""Moonshot parameter mappers for text.""" + +from celeste.parameters import ParameterMapper +from celeste.providers.moonshot.chat.parameters import ( + MaxTokensMapper as _MaxTokensMapper, +) +from celeste.providers.moonshot.chat.parameters import ( + ResponseFormatMapper as _ResponseFormatMapper, +) +from celeste.providers.moonshot.chat.parameters import ( + TemperatureMapper as _TemperatureMapper, +) + +from ...parameters import TextParameter + + +class TemperatureMapper(_TemperatureMapper): + """Map temperature to Moonshot's temperature parameter.""" + + name = TextParameter.TEMPERATURE + + +class MaxTokensMapper(_MaxTokensMapper): + """Map max_tokens to Moonshot's max_tokens parameter.""" + + name = TextParameter.MAX_TOKENS + + +class OutputSchemaMapper(_ResponseFormatMapper): + """Map output_schema to Moonshot's response_format parameter.""" + + name = TextParameter.OUTPUT_SCHEMA + + +MOONSHOT_PARAMETER_MAPPERS: list[ParameterMapper] = [ + TemperatureMapper(), + MaxTokensMapper(), + OutputSchemaMapper(), +] + +__all__ = ["MOONSHOT_PARAMETER_MAPPERS"] diff --git a/src/celeste/modalities/text/providers/openai/__init__.py b/src/celeste/modalities/text/providers/openai/__init__.py new file mode 100644 index 00000000..31ea0333 --- /dev/null +++ b/src/celeste/modalities/text/providers/openai/__init__.py @@ -0,0 +1,6 @@ +"""OpenAI provider for text modality.""" + +from .client import OpenAITextClient +from .models import MODELS + +__all__ = ["MODELS", "OpenAITextClient"] diff --git a/src/celeste/modalities/text/providers/openai/client.py b/src/celeste/modalities/text/providers/openai/client.py new file mode 100644 index 00000000..24294f58 --- /dev/null +++ b/src/celeste/modalities/text/providers/openai/client.py @@ -0,0 +1,166 @@ +"""OpenAI text client.""" + +from typing import Any, Unpack + +from celeste.parameters import ParameterMapper +from celeste.providers.openai.responses.client import ( + OpenAIResponsesClient as OpenAIResponsesMixin, +) +from celeste.providers.openai.responses.streaming import ( + OpenAIResponsesStream as _OpenAIResponsesStream, +) +from celeste.types import ImageContent, TextContent, VideoContent +from celeste.utils import build_image_data_url + +from ...client import TextClient +from ...io import ( + TextChunk, + TextFinishReason, + TextInput, + TextOutput, + TextUsage, +) +from ...parameters import TextParameters +from ...streaming import TextStream +from .parameters import OPENAI_PARAMETER_MAPPERS + + +class OpenAITextStream(_OpenAIResponsesStream, TextStream): + """OpenAI streaming for text modality.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._response_data: dict[str, Any] | None = None + + def _parse_chunk_usage(self, event_data: dict[str, Any]) -> TextUsage | None: + """Parse and wrap usage from SSE event.""" + usage = super()._parse_chunk_usage(event_data) + if usage: + return TextUsage(**usage) + return None + + def _parse_chunk_finish_reason( + self, event_data: dict[str, Any] + ) -> TextFinishReason | None: + """Parse and wrap finish reason from SSE event.""" + finish_reason = super()._parse_chunk_finish_reason(event_data) + if finish_reason: + return TextFinishReason(reason=finish_reason.reason) + return None + + def _parse_chunk(self, event_data: dict[str, Any]) -> TextChunk | None: + """Parse one SSE event into a typed chunk.""" + event_type = event_data.get("type") + if event_type == "response.completed": + response = event_data.get("response") + if isinstance(response, dict): + self._response_data = response + + content = self._parse_chunk_content(event_data) + if content is None: + usage = self._parse_chunk_usage(event_data) + finish_reason = self._parse_chunk_finish_reason(event_data) + if usage is None and finish_reason is None: + return None + content = "" + + return TextChunk( + content=content, + finish_reason=self._parse_chunk_finish_reason(event_data), + usage=self._parse_chunk_usage(event_data), + metadata={"event_data": event_data}, + ) + + def _aggregate_content(self, chunks: list[TextChunk]) -> str: + """Aggregate streamed text content.""" + return "".join(chunk.content for chunk in chunks) + + def _aggregate_event_data(self, chunks: list[TextChunk]) -> list[dict[str, Any]]: + """Collect raw events (filtering happens in _build_stream_metadata).""" + events: list[dict[str, Any]] = [] + if self._response_data is not None: + events.append(self._response_data) + for chunk in chunks: + event_data = chunk.metadata.get("event_data") + if isinstance(event_data, dict): + events.append(event_data) + return events + + +class OpenAITextClient(OpenAIResponsesMixin, TextClient): + """OpenAI text client using Responses API.""" + + @classmethod + def parameter_mappers(cls) -> list[ParameterMapper]: + return OPENAI_PARAMETER_MAPPERS + + async def generate( + self, + prompt: str, + **parameters: Unpack[TextParameters], + ) -> TextOutput: + """Generate text from prompt.""" + inputs = TextInput(prompt=prompt) + return await self._predict(inputs, **parameters) + + async def analyze( + self, + prompt: str, + *, + image: ImageContent | None = None, + video: VideoContent | None = None, + **parameters: Unpack[TextParameters], + ) -> TextOutput: + """Analyze image(s) or video(s) with prompt.""" + inputs = TextInput(prompt=prompt, image=image, video=video) + return await self._predict(inputs, **parameters) + + def _init_request(self, inputs: TextInput) -> dict[str, Any]: + """Initialize request with input content.""" + content: list[dict[str, Any]] = [] + + if inputs.image is not None: + images = inputs.image if isinstance(inputs.image, list) else [inputs.image] + for img in images: + content.append( + {"type": "input_image", "image_url": build_image_data_url(img)} + ) + + content.append({"type": "input_text", "text": inputs.prompt}) + + return {"input": [{"role": "user", "content": content}]} + + def _parse_usage(self, response_data: dict[str, Any]) -> TextUsage: + """Parse usage from response.""" + usage = super()._parse_usage(response_data) + return TextUsage(**usage) + + def _parse_content( + self, + response_data: dict[str, Any], + **parameters: Unpack[TextParameters], + ) -> TextContent: + """Parse text content from response.""" + output = super()._parse_content(response_data) + + # Extract text from OpenAI Responses API format + for item in output: + if item.get("type") == "message": + for part in item.get("content", []): + if part.get("type") == "output_text": + text = part.get("text") or "" + return self._transform_output(text, **parameters) + + return self._transform_output("", **parameters) + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> TextFinishReason: + """Parse finish reason from response.""" + base_reason = super()._parse_finish_reason(response_data) + return TextFinishReason(reason=base_reason.reason) + + def _stream_class(self) -> type[TextStream]: + """Return the Stream class for this provider.""" + return OpenAITextStream + + +__all__ = ["OpenAITextClient", "OpenAITextStream"] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/models.py b/src/celeste/modalities/text/providers/openai/models.py similarity index 54% rename from packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/models.py rename to src/celeste/modalities/text/providers/openai/models.py index 6e66ebfd..c9b03e15 100644 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/models.py +++ b/src/celeste/modalities/text/providers/openai/models.py @@ -1,47 +1,63 @@ -"""OpenAI models for text generation.""" +"""OpenAI models for text modality.""" -from celeste import Model, Provider -from celeste.constraints import Choice, Range, Schema -from celeste.core import Parameter -from celeste_text_generation.parameters import TextGenerationParameter +from celeste.constraints import ( + Bool, + Choice, + 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="gpt-4o", provider=Provider.OPENAI, display_name="GPT-4o", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, streaming=True, parameter_constraints={ Parameter.TEMPERATURE: Range(min=0.0, max=2.0), Parameter.MAX_TOKENS: Range(min=1, max=16384), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.WEB_SEARCH: Bool(), + TextParameter.IMAGE: ImagesConstraint(), }, ), Model( id="gpt-4o-mini", provider=Provider.OPENAI, display_name="GPT-4o Mini", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, streaming=True, parameter_constraints={ Parameter.TEMPERATURE: Range(min=0.0, max=2.0), Parameter.MAX_TOKENS: Range(min=1, max=16384), + TextParameter.WEB_SEARCH: Bool(), + TextParameter.IMAGE: ImagesConstraint(), }, ), Model( id="gpt-4-turbo", provider=Provider.OPENAI, display_name="GPT-4 Turbo", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, streaming=True, parameter_constraints={ Parameter.TEMPERATURE: Range(min=0.0, max=2.0), Parameter.MAX_TOKENS: Range(min=1, max=4096), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.IMAGE: ImagesConstraint(), }, ), Model( id="gpt-4", provider=Provider.OPENAI, display_name="GPT-4", + operations={Modality.TEXT: {Operation.GENERATE}}, streaming=True, parameter_constraints={ Parameter.TEMPERATURE: Range(min=0.0, max=2.0), @@ -52,6 +68,7 @@ id="gpt-3.5-turbo", provider=Provider.OPENAI, display_name="GPT-3.5 Turbo", + operations={Modality.TEXT: {Operation.GENERATE}}, streaming=True, parameter_constraints={ Parameter.TEMPERATURE: Range(min=0.0, max=2.0), @@ -62,137 +79,157 @@ id="gpt-5", provider=Provider.OPENAI, display_name="GPT-5", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, streaming=True, parameter_constraints={ Parameter.MAX_TOKENS: Range(min=1, max=128000), - TextGenerationParameter.THINKING_BUDGET: Choice( + TextParameter.THINKING_BUDGET: Choice( options=["minimal", "low", "medium", "high"] ), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.WEB_SEARCH: Bool(), + TextParameter.IMAGE: ImagesConstraint(), }, ), Model( id="gpt-5.2", provider=Provider.OPENAI, display_name="GPT-5.2", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, streaming=True, parameter_constraints={ Parameter.MAX_TOKENS: Range(min=1, max=128000), - TextGenerationParameter.THINKING_BUDGET: Choice( + TextParameter.THINKING_BUDGET: Choice( options=["minimal", "low", "medium", "high", "xhigh"] ), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.WEB_SEARCH: Bool(), + TextParameter.IMAGE: ImagesConstraint(), }, ), Model( id="gpt-5.2-pro", provider=Provider.OPENAI, display_name="GPT-5.2 Pro", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, streaming=True, parameter_constraints={ Parameter.MAX_TOKENS: Range(min=1, max=128000), - TextGenerationParameter.THINKING_BUDGET: Choice( + TextParameter.THINKING_BUDGET: Choice( options=["minimal", "low", "medium", "high", "xhigh"] ), - TextGenerationParameter.VERBOSITY: Choice( - options=["low", "medium", "high"] - ), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.VERBOSITY: Choice(options=["low", "medium", "high"]), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.WEB_SEARCH: Bool(), + TextParameter.IMAGE: ImagesConstraint(), }, ), Model( id="gpt-5.2-codex", provider=Provider.OPENAI, display_name="GPT-5.2 Codex", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, streaming=True, parameter_constraints={ Parameter.MAX_TOKENS: Range(min=1, max=128000), - TextGenerationParameter.THINKING_BUDGET: Choice( + TextParameter.THINKING_BUDGET: Choice( options=["low", "medium", "high", "xhigh"] ), - TextGenerationParameter.VERBOSITY: Choice( - options=["low", "medium", "high"] - ), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.IMAGE: ImagesConstraint(), }, ), Model( id="gpt-5.2-chat-latest", provider=Provider.OPENAI, display_name="GPT-5.2 Instant", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, streaming=True, parameter_constraints={ Parameter.TEMPERATURE: Range(min=0.0, max=2.0), Parameter.MAX_TOKENS: Range(min=1, max=128000), + TextParameter.WEB_SEARCH: Bool(), + TextParameter.IMAGE: ImagesConstraint(), }, ), Model( id="gpt-5.1", provider=Provider.OPENAI, display_name="GPT-5.1", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, streaming=True, parameter_constraints={ Parameter.MAX_TOKENS: Range(min=1, max=128000), - TextGenerationParameter.THINKING_BUDGET: Choice( + TextParameter.THINKING_BUDGET: Choice( options=["minimal", "low", "medium", "high"] ), - TextGenerationParameter.VERBOSITY: Choice( - options=["low", "medium", "high"] - ), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.VERBOSITY: Choice(options=["low", "medium", "high"]), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.WEB_SEARCH: Bool(), + TextParameter.IMAGE: ImagesConstraint(), }, ), Model( id="gpt-5.1-codex", provider=Provider.OPENAI, display_name="GPT-5.1 Codex", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, streaming=True, parameter_constraints={ Parameter.MAX_TOKENS: Range(min=1, max=128000), - TextGenerationParameter.THINKING_BUDGET: Choice( + TextParameter.THINKING_BUDGET: Choice( options=["minimal", "low", "medium", "high"] ), - TextGenerationParameter.VERBOSITY: Choice( - options=["low", "medium", "high"] - ), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.VERBOSITY: Choice(options=["low", "medium", "high"]), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.WEB_SEARCH: Bool(), + TextParameter.IMAGE: ImagesConstraint(), }, ), Model( id="gpt-5-mini", provider=Provider.OPENAI, display_name="GPT-5 Mini", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, streaming=True, parameter_constraints={ Parameter.MAX_TOKENS: Range(min=1, max=128000), - TextGenerationParameter.THINKING_BUDGET: Choice( + TextParameter.THINKING_BUDGET: Choice( options=["minimal", "low", "medium", "high"] ), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.WEB_SEARCH: Bool(), + TextParameter.IMAGE: ImagesConstraint(), }, ), Model( id="gpt-5-nano", provider=Provider.OPENAI, display_name="GPT-5 Nano", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, streaming=True, parameter_constraints={ Parameter.MAX_TOKENS: Range(min=1, max=128000), - TextGenerationParameter.THINKING_BUDGET: Choice( + TextParameter.THINKING_BUDGET: Choice( options=["minimal", "low", "medium", "high"] ), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.WEB_SEARCH: Bool(), + TextParameter.IMAGE: ImagesConstraint(), }, ), Model( id="gpt-4.1", provider=Provider.OPENAI, display_name="GPT-4.1", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, streaming=True, parameter_constraints={ Parameter.TEMPERATURE: Range(min=0.0, max=2.0), Parameter.MAX_TOKENS: Range(min=1, max=32768), - TextGenerationParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.WEB_SEARCH: Bool(), + TextParameter.IMAGE: ImagesConstraint(), }, ), ] diff --git a/src/celeste/modalities/text/providers/openai/parameters.py b/src/celeste/modalities/text/providers/openai/parameters.py new file mode 100644 index 00000000..53ff86d6 --- /dev/null +++ b/src/celeste/modalities/text/providers/openai/parameters.py @@ -0,0 +1,71 @@ +"""OpenAI parameter mappers for text.""" + +from celeste.parameters import ParameterMapper +from celeste.providers.openai.responses.parameters import ( + MaxOutputTokensMapper as _MaxOutputTokensMapper, +) +from celeste.providers.openai.responses.parameters import ( + ReasoningEffortMapper as _ReasoningEffortMapper, +) +from celeste.providers.openai.responses.parameters import ( + TemperatureMapper as _TemperatureMapper, +) +from celeste.providers.openai.responses.parameters import ( + TextFormatMapper as _TextFormatMapper, +) +from celeste.providers.openai.responses.parameters import ( + VerbosityMapper as _VerbosityMapper, +) +from celeste.providers.openai.responses.parameters import ( + WebSearchMapper as _WebSearchMapper, +) + +from ...parameters import TextParameter + + +class TemperatureMapper(_TemperatureMapper): + """Map temperature to OpenAI's temperature parameter.""" + + name = TextParameter.TEMPERATURE + + +class MaxTokensMapper(_MaxOutputTokensMapper): + """Map max_tokens to OpenAI's max_output_tokens parameter.""" + + name = TextParameter.MAX_TOKENS + + +class OutputSchemaMapper(_TextFormatMapper): + """Map output_schema to OpenAI's text.format parameter.""" + + name = TextParameter.OUTPUT_SCHEMA + + +class WebSearchMapper(_WebSearchMapper): + """Map web_search to OpenAI's tools parameter.""" + + name = TextParameter.WEB_SEARCH + + +class VerbosityMapper(_VerbosityMapper): + """Map verbosity to OpenAI's text.verbosity parameter.""" + + name = TextParameter.VERBOSITY + + +class ThinkingBudgetMapper(_ReasoningEffortMapper): + """Map thinking_budget to OpenAI's reasoning.effort parameter.""" + + name = TextParameter.THINKING_BUDGET + + +OPENAI_PARAMETER_MAPPERS: list[ParameterMapper] = [ + TemperatureMapper(), + MaxTokensMapper(), + OutputSchemaMapper(), + WebSearchMapper(), + VerbosityMapper(), + ThinkingBudgetMapper(), +] + +__all__ = ["OPENAI_PARAMETER_MAPPERS"] diff --git a/src/celeste/modalities/text/providers/xai/__init__.py b/src/celeste/modalities/text/providers/xai/__init__.py new file mode 100644 index 00000000..9e3d1a81 --- /dev/null +++ b/src/celeste/modalities/text/providers/xai/__init__.py @@ -0,0 +1,6 @@ +"""xAI provider for text modality.""" + +from .client import XAITextClient +from .models import MODELS + +__all__ = ["MODELS", "XAITextClient"] diff --git a/src/celeste/modalities/text/providers/xai/client.py b/src/celeste/modalities/text/providers/xai/client.py new file mode 100644 index 00000000..88e93f37 --- /dev/null +++ b/src/celeste/modalities/text/providers/xai/client.py @@ -0,0 +1,163 @@ +"""xAI text client (modality).""" + +from typing import Any, Unpack + +from celeste.parameters import ParameterMapper +from celeste.providers.xai.responses.client import XAIResponsesClient +from celeste.providers.xai.responses.streaming import ( + XAIResponsesStream as _XAIResponsesStream, +) +from celeste.types import ImageContent, TextContent, VideoContent +from celeste.utils import build_image_data_url + +from ...client import TextClient +from ...io import ( + TextChunk, + TextFinishReason, + TextInput, + TextOutput, + TextUsage, +) +from ...parameters import TextParameters +from ...streaming import TextStream +from .parameters import XAI_PARAMETER_MAPPERS + + +class XAITextStream(_XAIResponsesStream, TextStream): + """xAI streaming for text modality.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._response_data: dict[str, Any] | None = None + + def _parse_chunk_usage(self, event_data: dict[str, Any]) -> TextUsage | None: + """Parse and wrap usage from SSE event.""" + usage = super()._parse_chunk_usage(event_data) + if usage: + return TextUsage(**usage) + return None + + def _parse_chunk_finish_reason( + self, event_data: dict[str, Any] + ) -> TextFinishReason | None: + """Parse and wrap finish reason from SSE event.""" + finish_reason = super()._parse_chunk_finish_reason(event_data) + if finish_reason: + return TextFinishReason(reason=finish_reason.reason) + return None + + def _parse_chunk(self, event_data: dict[str, Any]) -> TextChunk | None: + """Parse one SSE event into a typed chunk.""" + event_type = event_data.get("type") + if event_type == "response.completed": + response = event_data.get("response") + if isinstance(response, dict): + self._response_data = response + + content = self._parse_chunk_content(event_data) + if content is None: + usage = self._parse_chunk_usage(event_data) + finish_reason = self._parse_chunk_finish_reason(event_data) + if usage is None and finish_reason is None: + return None + content = "" + + return TextChunk( + content=content, + finish_reason=self._parse_chunk_finish_reason(event_data), + usage=self._parse_chunk_usage(event_data), + metadata={"event_data": event_data}, + ) + + def _aggregate_content(self, chunks: list[TextChunk]) -> str: + """Aggregate streamed text content.""" + return "".join(chunk.content for chunk in chunks) + + def _aggregate_event_data(self, chunks: list[TextChunk]) -> list[dict[str, Any]]: + """Collect raw events (filtering happens in _build_stream_metadata).""" + events: list[dict[str, Any]] = [] + if self._response_data is not None: + events.append(self._response_data) + for chunk in chunks: + event_data = chunk.metadata.get("event_data") + if isinstance(event_data, dict): + events.append(event_data) + return events + + +class XAITextClient(XAIResponsesClient, TextClient): + """xAI text client.""" + + @classmethod + def parameter_mappers(cls) -> list[ParameterMapper]: + return XAI_PARAMETER_MAPPERS + + async def generate( + self, + prompt: str, + **parameters: Unpack[TextParameters], + ) -> TextOutput: + """Generate text from prompt.""" + inputs = TextInput(prompt=prompt) + return await self._predict(inputs, **parameters) + + async def analyze( + self, + prompt: str, + *, + image: ImageContent | None = None, + video: VideoContent | None = None, + **parameters: Unpack[TextParameters], + ) -> TextOutput: + """Analyze image(s) or video(s) with prompt.""" + inputs = TextInput(prompt=prompt, image=image, video=video) + return await self._predict(inputs, **parameters) + + def _init_request(self, inputs: TextInput) -> dict[str, Any]: + """Initialize request from XAI Responses API format.""" + if inputs.image is None: + return {"input": inputs.prompt} + + # Multimodal: build content array with images + text + images = inputs.image if isinstance(inputs.image, list) else [inputs.image] + content: list[dict[str, Any]] = [] + for img in images: + content.append( + {"type": "input_image", "image_url": build_image_data_url(img)} + ) + content.append({"type": "input_text", "text": inputs.prompt}) + + return {"input": [{"role": "user", "content": content}]} + + def _parse_usage(self, response_data: dict[str, Any]) -> TextUsage: + """Parse usage from response.""" + usage = super()._parse_usage(response_data) + return TextUsage(**usage) + + def _parse_content( + self, + response_data: dict[str, Any], + **parameters: Unpack[TextParameters], + ) -> TextContent: + """Parse content from response.""" + output = super()._parse_content(response_data) + for item in output: + if item.get("type") == "message": + for part in item.get("content", []): + if part.get("type") == "output_text": + text = part.get("text") or "" + return self._transform_output(text, **parameters) + + return self._transform_output("", **parameters) + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> TextFinishReason: + """Parse finish reason from response.""" + finish_reason = super()._parse_finish_reason(response_data) + return TextFinishReason(reason=finish_reason.reason) + + def _stream_class(self) -> type[TextStream]: + """Return the Stream class for this provider.""" + return XAITextStream + + +__all__ = ["XAITextClient", "XAITextStream"] diff --git a/src/celeste/modalities/text/providers/xai/models.py b/src/celeste/modalities/text/providers/xai/models.py new file mode 100644 index 00000000..83288806 --- /dev/null +++ b/src/celeste/modalities/text/providers/xai/models.py @@ -0,0 +1,119 @@ +"""xAI models for text modality.""" + +from celeste.constraints import ( + Bool, + Choice, + 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="grok-4-1-fast-reasoning", + provider=Provider.XAI, + display_name="Grok 4.1 Fast Reasoning", + operations={Modality.TEXT: {Operation.GENERATE}}, + streaming=True, + parameter_constraints={ + Parameter.TEMPERATURE: Range(min=0.0, max=2.0), + Parameter.MAX_TOKENS: Range(min=1, max=30000), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.WEB_SEARCH: Bool(), + TextParameter.X_SEARCH: Bool(), + TextParameter.CODE_EXECUTION: Bool(), + }, + ), + Model( + id="grok-4-1-fast-non-reasoning", + provider=Provider.XAI, + display_name="Grok 4.1 Fast Non-Reasoning", + operations={Modality.TEXT: {Operation.GENERATE}}, + streaming=True, + parameter_constraints={ + Parameter.TEMPERATURE: Range(min=0.0, max=2.0), + Parameter.MAX_TOKENS: Range(min=1, max=30000), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.WEB_SEARCH: Bool(), + TextParameter.X_SEARCH: Bool(), + TextParameter.CODE_EXECUTION: Bool(), + }, + ), + Model( + id="grok-4-fast-reasoning", + provider=Provider.XAI, + display_name="Grok 4 Fast Reasoning", + operations={Modality.TEXT: {Operation.GENERATE}}, + streaming=True, + parameter_constraints={ + Parameter.TEMPERATURE: Range(min=0.0, max=2.0), + Parameter.MAX_TOKENS: Range(min=1, max=30000), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.WEB_SEARCH: Bool(), + TextParameter.X_SEARCH: Bool(), + TextParameter.CODE_EXECUTION: Bool(), + }, + ), + Model( + id="grok-4-fast-non-reasoning", + provider=Provider.XAI, + display_name="Grok 4 Fast Non-Reasoning", + operations={Modality.TEXT: {Operation.GENERATE}}, + streaming=True, + parameter_constraints={ + Parameter.TEMPERATURE: Range(min=0.0, max=2.0), + Parameter.MAX_TOKENS: Range(min=1, max=30000), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.WEB_SEARCH: Bool(), + TextParameter.X_SEARCH: Bool(), + TextParameter.CODE_EXECUTION: Bool(), + }, + ), + Model( + id="grok-4-0709", + provider=Provider.XAI, + display_name="Grok 4", + operations={Modality.TEXT: {Operation.GENERATE}}, + streaming=True, + parameter_constraints={ + Parameter.TEMPERATURE: Range(min=0.0, max=2.0), + Parameter.MAX_TOKENS: Range(min=1, max=64000), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.WEB_SEARCH: Bool(), + TextParameter.X_SEARCH: Bool(), + TextParameter.CODE_EXECUTION: Bool(), + }, + ), + Model( + id="grok-3-mini", + provider=Provider.XAI, + display_name="Grok 3 Mini", + operations={Modality.TEXT: {Operation.GENERATE}}, + streaming=True, + parameter_constraints={ + Parameter.TEMPERATURE: Range(min=0.0, max=2.0), + Parameter.MAX_TOKENS: Range(min=1, max=16000), + TextParameter.THINKING_BUDGET: Choice(options=["low", "high"]), + TextParameter.OUTPUT_SCHEMA: Schema(), + TextParameter.WEB_SEARCH: Bool(), + TextParameter.X_SEARCH: Bool(), + TextParameter.CODE_EXECUTION: Bool(), + }, + ), + Model( + id="grok-2-vision-1212", + provider=Provider.XAI, + display_name="Grok 2 Vision", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, + streaming=True, + parameter_constraints={ + Parameter.TEMPERATURE: Range(min=0.0, max=2.0), + Parameter.MAX_TOKENS: Range(min=1, max=32768), + TextParameter.IMAGE: ImagesConstraint(), + }, + ), +] diff --git a/src/celeste/modalities/text/providers/xai/parameters.py b/src/celeste/modalities/text/providers/xai/parameters.py new file mode 100644 index 00000000..a9c42c6b --- /dev/null +++ b/src/celeste/modalities/text/providers/xai/parameters.py @@ -0,0 +1,81 @@ +"""xAI parameter mappers for text.""" + +from celeste.parameters import ParameterMapper +from celeste.providers.xai.responses.parameters import ( + CodeExecutionMapper as _CodeExecutionMapper, +) +from celeste.providers.xai.responses.parameters import ( + MaxOutputTokensMapper as _MaxOutputTokensMapper, +) +from celeste.providers.xai.responses.parameters import ( + ReasoningEffortMapper as _ReasoningEffortMapper, +) +from celeste.providers.xai.responses.parameters import ( + TemperatureMapper as _TemperatureMapper, +) +from celeste.providers.xai.responses.parameters import ( + TextFormatMapper as _TextFormatMapper, +) +from celeste.providers.xai.responses.parameters import ( + WebSearchMapper as _WebSearchMapper, +) +from celeste.providers.xai.responses.parameters import ( + XSearchMapper as _XSearchMapper, +) + +from ...parameters import TextParameter + + +class TemperatureMapper(_TemperatureMapper): + """Map temperature to xAI's temperature parameter.""" + + name = TextParameter.TEMPERATURE + + +class MaxTokensMapper(_MaxOutputTokensMapper): + """Map max_tokens to xAI's max_output_tokens parameter.""" + + name = TextParameter.MAX_TOKENS + + +class ThinkingBudgetMapper(_ReasoningEffortMapper): + """Map thinking_budget to xAI's reasoning.effort parameter.""" + + name = TextParameter.THINKING_BUDGET + + +class OutputSchemaMapper(_TextFormatMapper): + """Map output_schema to xAI's text.format parameter.""" + + name = TextParameter.OUTPUT_SCHEMA + + +class WebSearchMapper(_WebSearchMapper): + """Map web_search to xAI's tools parameter.""" + + name = TextParameter.WEB_SEARCH + + +class XSearchMapper(_XSearchMapper): + """Map x_search to xAI's tools parameter.""" + + name = TextParameter.X_SEARCH + + +class CodeExecutionMapper(_CodeExecutionMapper): + """Map code_execution to xAI's tools parameter.""" + + name = TextParameter.CODE_EXECUTION + + +XAI_PARAMETER_MAPPERS: list[ParameterMapper] = [ + TemperatureMapper(), + MaxTokensMapper(), + ThinkingBudgetMapper(), + OutputSchemaMapper(), + WebSearchMapper(), + XSearchMapper(), + CodeExecutionMapper(), +] + +__all__ = ["XAI_PARAMETER_MAPPERS"] diff --git a/packages/providers/anthropic/src/celeste_anthropic/py.typed b/src/celeste/modalities/text/py.typed similarity index 100% rename from packages/providers/anthropic/src/celeste_anthropic/py.typed rename to src/celeste/modalities/text/py.typed diff --git a/src/celeste/modalities/text/streaming.py b/src/celeste/modalities/text/streaming.py new file mode 100644 index 00000000..072c740f --- /dev/null +++ b/src/celeste/modalities/text/streaming.py @@ -0,0 +1,85 @@ +"""Text streaming primitives.""" + +from abc import abstractmethod +from collections.abc import AsyncIterator, Callable +from typing import Any, Unpack + +from celeste.client import ModalityClient +from celeste.streaming import Stream +from celeste.types import TextContent + +from .io import TextChunk, TextFinishReason, TextOutput, TextUsage +from .parameters import TextParameters + + +class TextStream(Stream[TextOutput, TextParameters, TextChunk]): + """Streaming for text modality.""" + + def __init__( + self, + sse_iterator: AsyncIterator[dict[str, Any]], + transform_output: Callable[..., TextContent], + client: ModalityClient, + **parameters: Unpack[TextParameters], + ) -> None: + """Initialize stream with output transformation support.""" + super().__init__(sse_iterator, **parameters) + self._transform_output = transform_output + self._client = client + + @abstractmethod + def _aggregate_content(self, chunks: list[TextChunk]) -> str: + """Aggregate content from chunks into raw text.""" + ... + + def _aggregate_usage(self, chunks: list[TextChunk]) -> TextUsage: + """Aggregate usage across chunks (universal).""" + for chunk in reversed(chunks): + if chunk.usage: + return chunk.usage + return TextUsage() + + def _aggregate_finish_reason( + self, + chunks: list[TextChunk], + ) -> TextFinishReason | None: + """Aggregate finish reason across chunks (universal).""" + for chunk in reversed(chunks): + if chunk.finish_reason: + return chunk.finish_reason + return None + + @abstractmethod + def _aggregate_event_data(self, chunks: list[TextChunk]) -> list[dict[str, Any]]: + """Collect raw events (filtering happens in _build_stream_metadata).""" + ... + + def _build_stream_metadata( + self, raw_events: list[dict[str, Any]] + ) -> dict[str, Any]: + """Build streaming metadata. Provider API Stream overrides to filter content.""" + return { + "model": self._client.model.id, + "provider": self._client.provider, + "modality": self._client.modality, + "raw_events": raw_events, + } + + def _parse_output( + self, + chunks: list[TextChunk], + **parameters: Unpack[TextParameters], + ) -> TextOutput: + """Assemble chunks into final output.""" + raw_content = self._aggregate_content(chunks) + content: TextContent = self._transform_output(raw_content, **parameters) + raw_events = self._aggregate_event_data(chunks) + return TextOutput( + content=content, + usage=self._aggregate_usage(chunks), + finish_reason=self._aggregate_finish_reason(chunks), + metadata=self._build_stream_metadata(raw_events), + ) + + +__all__ = ["TextStream"] diff --git a/src/celeste/modalities/videos/__init__.py b/src/celeste/modalities/videos/__init__.py new file mode 100644 index 00000000..b7f6f535 --- /dev/null +++ b/src/celeste/modalities/videos/__init__.py @@ -0,0 +1,22 @@ +"""Celeste Videos modality.""" + +from .client import VideosClient +from .io import ( + VideoChunk, + VideoFinishReason, + VideoInput, + VideoOutput, + VideoUsage, +) +from .parameters import VideoParameter, VideoParameters + +__all__ = [ + "VideoChunk", + "VideoFinishReason", + "VideoInput", + "VideoOutput", + "VideoParameter", + "VideoParameters", + "VideoUsage", + "VideosClient", +] diff --git a/src/celeste/modalities/videos/client.py b/src/celeste/modalities/videos/client.py new file mode 100644 index 00000000..618989e1 --- /dev/null +++ b/src/celeste/modalities/videos/client.py @@ -0,0 +1,60 @@ +"""Videos modality client.""" + +from typing import Unpack + +from asgiref.sync import async_to_sync + +from celeste.client import ModalityClient +from celeste.core import Modality +from celeste.types import VideoContent + +from .io import VideoInput, VideoOutput +from .parameters import VideoParameters + + +class VideosClient( + ModalityClient[VideoInput, VideoOutput, VideoParameters, VideoContent] +): + """Base videos client. Providers implement generate method.""" + + modality: Modality = Modality.VIDEOS + + @classmethod + def _output_class(cls) -> type[VideoOutput]: + """Return the Output class for videos modality.""" + return VideoOutput + + @property + def sync(self) -> "VideosSyncNamespace": + """Sync namespace for videos operations.""" + return VideosSyncNamespace(self) + + +class VideosSyncNamespace: + """Sync namespace for videos operations. + + Provides `client.sync.generate()`. + """ + + def __init__(self, client: VideosClient) -> None: + self._client = client + + def generate( + self, + prompt: str, + **parameters: Unpack[VideoParameters], + ) -> VideoOutput: + """Blocking video generation. + + Usage: + result = client.sync.generate("A cat walking on the beach") + result.content.save("video.mp4") + """ + inputs = VideoInput(prompt=prompt) + return async_to_sync(self._client._predict)(inputs, **parameters) + + +__all__ = [ + "VideosClient", + "VideosSyncNamespace", +] diff --git a/src/celeste/modalities/videos/io.py b/src/celeste/modalities/videos/io.py new file mode 100644 index 00000000..ec4643e6 --- /dev/null +++ b/src/celeste/modalities/videos/io.py @@ -0,0 +1,52 @@ +"""IO types for videos modality.""" + +from pydantic import Field + +from celeste.artifacts import VideoArtifact +from celeste.io import Chunk, FinishReason, Input, Output, Usage + + +class VideoInput(Input): + """Input for video generation operations.""" + + prompt: str + + +class VideoFinishReason(FinishReason): + """Video generation finish reason.""" + + reason: str | None = None + message: str | None = None + + +class VideoUsage(Usage): + """Video generation usage metrics. + + All fields optional since providers vary. + """ + + total_tokens: int | None = None + billed_units: float | None = None + + +class VideoOutput(Output[VideoArtifact]): + """Output from video generation operations.""" + + usage: VideoUsage = Field(default_factory=VideoUsage) + finish_reason: VideoFinishReason | None = None + + +class VideoChunk(Chunk[VideoArtifact]): + """Chunk for video streaming (not typically used - video generation doesn't stream).""" + + finish_reason: VideoFinishReason | None = None + usage: VideoUsage | None = None + + +__all__ = [ + "VideoChunk", + "VideoFinishReason", + "VideoInput", + "VideoOutput", + "VideoUsage", +] diff --git a/src/celeste/modalities/videos/models.py b/src/celeste/modalities/videos/models.py new file mode 100644 index 00000000..f4821c93 --- /dev/null +++ b/src/celeste/modalities/videos/models.py @@ -0,0 +1,13 @@ +"""Aggregated models for videos modality.""" + +from celeste.models import Model + +from .providers.byteplus.models import MODELS as BYTEPLUS_MODELS +from .providers.google.models import MODELS as GOOGLE_MODELS +from .providers.openai.models import MODELS as OPENAI_MODELS + +MODELS: list[Model] = [ + *BYTEPLUS_MODELS, + *GOOGLE_MODELS, + *OPENAI_MODELS, +] diff --git a/src/celeste/modalities/videos/parameters.py b/src/celeste/modalities/videos/parameters.py new file mode 100644 index 00000000..c4680e4b --- /dev/null +++ b/src/celeste/modalities/videos/parameters.py @@ -0,0 +1,34 @@ +"""Parameters for videos modality.""" + +from enum import StrEnum + +from celeste.artifacts import ImageArtifact +from celeste.parameters import Parameters + + +class VideoParameter(StrEnum): + """Unified parameter names for video generation.""" + + ASPECT_RATIO = "aspect_ratio" + RESOLUTION = "resolution" + DURATION = "duration" + REFERENCE_IMAGES = "reference_images" + FIRST_FRAME = "first_frame" + LAST_FRAME = "last_frame" + + +class VideoParameters(Parameters): + """Parameters for video generation operations.""" + + aspect_ratio: str + resolution: str + duration: int + reference_images: list[ImageArtifact] + first_frame: ImageArtifact + last_frame: ImageArtifact + + +__all__ = [ + "VideoParameter", + "VideoParameters", +] diff --git a/src/celeste/modalities/videos/providers/__init__.py b/src/celeste/modalities/videos/providers/__init__.py new file mode 100644 index 00000000..71088861 --- /dev/null +++ b/src/celeste/modalities/videos/providers/__init__.py @@ -0,0 +1,14 @@ +"""Videos providers.""" + +from celeste.core import Provider + +from ..client import VideosClient +from .byteplus import BytePlusVideosClient +from .google import GoogleVideosClient +from .openai import OpenAIVideosClient + +PROVIDERS: dict[Provider, type[VideosClient]] = { + Provider.BYTEPLUS: BytePlusVideosClient, + Provider.GOOGLE: GoogleVideosClient, + Provider.OPENAI: OpenAIVideosClient, +} diff --git a/src/celeste/modalities/videos/providers/byteplus/__init__.py b/src/celeste/modalities/videos/providers/byteplus/__init__.py new file mode 100644 index 00000000..46ba8e9d --- /dev/null +++ b/src/celeste/modalities/videos/providers/byteplus/__init__.py @@ -0,0 +1,6 @@ +"""BytePlus provider for videos modality.""" + +from .client import BytePlusVideosClient +from .models import MODELS + +__all__ = ["MODELS", "BytePlusVideosClient"] diff --git a/src/celeste/modalities/videos/providers/byteplus/client.py b/src/celeste/modalities/videos/providers/byteplus/client.py new file mode 100644 index 00000000..afe9036b --- /dev/null +++ b/src/celeste/modalities/videos/providers/byteplus/client.py @@ -0,0 +1,68 @@ +"""BytePlus videos client.""" + +from typing import Any, Unpack + +from celeste.artifacts import VideoArtifact +from celeste.parameters import ParameterMapper +from celeste.providers.byteplus.videos import config +from celeste.providers.byteplus.videos.client import ( + BytePlusVideosClient as BytePlusVideosMixin, +) + +from ...client import VideosClient +from ...io import VideoFinishReason, VideoInput, VideoOutput, VideoUsage +from ...parameters import VideoParameters +from .parameters import BYTEPLUS_PARAMETER_MAPPERS + + +class BytePlusVideosClient(BytePlusVideosMixin, VideosClient): + """BytePlus client for video generation.""" + + @classmethod + def parameter_mappers(cls) -> list[ParameterMapper]: + return BYTEPLUS_PARAMETER_MAPPERS + + def _init_request(self, inputs: VideoInput) -> dict[str, Any]: + """Initialize request from BytePlus ModelArk API format.""" + return { + "content": [{"type": "text", "text": inputs.prompt}], + } + + async def generate( + self, + prompt: str, + **parameters: Unpack[VideoParameters], + ) -> VideoOutput: + """Generate videos from prompt.""" + inputs = VideoInput(prompt=prompt) + return await self._predict( + inputs, + endpoint=config.BytePlusVideosEndpoint.CREATE_VIDEO, + **parameters, + ) + + def _parse_usage(self, response_data: dict[str, Any]) -> VideoUsage: + """Parse usage from response.""" + usage = super()._parse_usage(response_data) + return VideoUsage(**usage) + + def _parse_content( + self, + response_data: dict[str, Any], + **parameters: Unpack[VideoParameters], + ) -> VideoArtifact: + """Parse content from response.""" + content = super()._parse_content(response_data) + video_url = content.get("video_url") + if not video_url: + msg = "No video_url in response content" + raise ValueError(msg) + return VideoArtifact(url=video_url) + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> VideoFinishReason: + """Parse finish reason from response.""" + finish_reason = super()._parse_finish_reason(response_data) + return VideoFinishReason(reason=finish_reason.reason) + + +__all__ = ["BytePlusVideosClient"] diff --git a/packages/capabilities/video-generation/src/celeste_video_generation/providers/byteplus/models.py b/src/celeste/modalities/videos/providers/byteplus/models.py similarity index 53% rename from packages/capabilities/video-generation/src/celeste_video_generation/providers/byteplus/models.py rename to src/celeste/modalities/videos/providers/byteplus/models.py index 732c1f9a..7c7d02a9 100644 --- a/packages/capabilities/video-generation/src/celeste_video_generation/providers/byteplus/models.py +++ b/src/celeste/modalities/videos/providers/byteplus/models.py @@ -1,13 +1,15 @@ -"""BytePlus models for video generation. +"""BytePlus models for videos modality. Model IDs use lowercase format with version suffixes (e.g., seedance-1-0-pro-250528). Console display names differ from API model IDs. """ -from celeste import Capability, Model, Provider from celeste.constraints import Choice, ImageConstraint, ImagesConstraint, Range +from celeste.core import Modality, Operation, Provider from celeste.mime_types import ImageMimeType -from celeste_video_generation.parameters import VideoGenerationParameter +from celeste.models import Model + +from ...parameters import VideoParameter # Supported MIME types for BytePlus image parameters BYTEPLUS_SUPPORTED_MIME_TYPES = [ @@ -23,17 +25,15 @@ Model( id="seedance-1-0-lite-t2v-250428", provider=Provider.BYTEPLUS, - capabilities={Capability.VIDEO_GENERATION}, + operations={Modality.VIDEOS: {Operation.GENERATE}}, display_name="Seedance 1.0 Lite (Text-to-Video)", parameter_constraints={ - VideoGenerationParameter.DURATION: Range(min=2, max=12), - VideoGenerationParameter.RESOLUTION: Choice( - options=["480p", "720p", "1080p"] - ), - VideoGenerationParameter.FIRST_FRAME: ImageConstraint( + VideoParameter.DURATION: Range(min=2, max=12), + VideoParameter.RESOLUTION: Choice(options=["480p", "720p", "1080p"]), + VideoParameter.FIRST_FRAME: ImageConstraint( supported_mime_types=BYTEPLUS_SUPPORTED_MIME_TYPES, ), - VideoGenerationParameter.LAST_FRAME: ImageConstraint( + VideoParameter.LAST_FRAME: ImageConstraint( supported_mime_types=BYTEPLUS_SUPPORTED_MIME_TYPES, ), }, @@ -41,21 +41,19 @@ Model( id="seedance-1-0-lite-i2v-250428", provider=Provider.BYTEPLUS, - capabilities={Capability.VIDEO_GENERATION}, + operations={Modality.VIDEOS: {Operation.GENERATE}}, display_name="Seedance 1.0 Lite (Image-to-Video)", parameter_constraints={ - VideoGenerationParameter.DURATION: Range(min=2, max=12), - VideoGenerationParameter.RESOLUTION: Choice( - options=["480p", "720p", "1080p"] - ), - VideoGenerationParameter.REFERENCE_IMAGES: ImagesConstraint( + VideoParameter.DURATION: Range(min=2, max=12), + VideoParameter.RESOLUTION: Choice(options=["480p", "720p", "1080p"]), + VideoParameter.REFERENCE_IMAGES: ImagesConstraint( supported_mime_types=BYTEPLUS_SUPPORTED_MIME_TYPES, max_count=4, ), - VideoGenerationParameter.FIRST_FRAME: ImageConstraint( + VideoParameter.FIRST_FRAME: ImageConstraint( supported_mime_types=BYTEPLUS_SUPPORTED_MIME_TYPES, ), - VideoGenerationParameter.LAST_FRAME: ImageConstraint( + VideoParameter.LAST_FRAME: ImageConstraint( supported_mime_types=BYTEPLUS_SUPPORTED_MIME_TYPES, ), }, @@ -63,17 +61,15 @@ Model( id="seedance-1-0-pro-250528", provider=Provider.BYTEPLUS, - capabilities={Capability.VIDEO_GENERATION}, + operations={Modality.VIDEOS: {Operation.GENERATE}}, display_name="Seedance 1.0 Pro", parameter_constraints={ - VideoGenerationParameter.DURATION: Range(min=2, max=12), - VideoGenerationParameter.RESOLUTION: Choice( - options=["480p", "720p", "1080p"] - ), - VideoGenerationParameter.FIRST_FRAME: ImageConstraint( + VideoParameter.DURATION: Range(min=2, max=12), + VideoParameter.RESOLUTION: Choice(options=["480p", "720p", "1080p"]), + VideoParameter.FIRST_FRAME: ImageConstraint( supported_mime_types=BYTEPLUS_SUPPORTED_MIME_TYPES, ), - VideoGenerationParameter.LAST_FRAME: ImageConstraint( + VideoParameter.LAST_FRAME: ImageConstraint( supported_mime_types=BYTEPLUS_SUPPORTED_MIME_TYPES, ), }, @@ -81,17 +77,15 @@ Model( id="seedance-1-0-pro-fast-251015", provider=Provider.BYTEPLUS, - capabilities={Capability.VIDEO_GENERATION}, + operations={Modality.VIDEOS: {Operation.GENERATE}}, display_name="Seedance 1.0 Pro Fast", parameter_constraints={ - VideoGenerationParameter.DURATION: Range(min=2, max=12), - VideoGenerationParameter.RESOLUTION: Choice( - options=["480p", "720p", "1080p"] - ), - VideoGenerationParameter.FIRST_FRAME: ImageConstraint( + VideoParameter.DURATION: Range(min=2, max=12), + VideoParameter.RESOLUTION: Choice(options=["480p", "720p", "1080p"]), + VideoParameter.FIRST_FRAME: ImageConstraint( supported_mime_types=BYTEPLUS_SUPPORTED_MIME_TYPES, ), - VideoGenerationParameter.LAST_FRAME: ImageConstraint( + VideoParameter.LAST_FRAME: ImageConstraint( supported_mime_types=BYTEPLUS_SUPPORTED_MIME_TYPES, ), }, @@ -99,15 +93,15 @@ Model( id="seedance-1-5-pro-251215", provider=Provider.BYTEPLUS, - capabilities={Capability.VIDEO_GENERATION}, + operations={Modality.VIDEOS: {Operation.GENERATE}}, display_name="Seedance 1.5 Pro", parameter_constraints={ - VideoGenerationParameter.DURATION: Range(min=4, max=12), - VideoGenerationParameter.RESOLUTION: Choice(options=["480p", "720p"]), - VideoGenerationParameter.FIRST_FRAME: ImageConstraint( + VideoParameter.DURATION: Range(min=4, max=12), + VideoParameter.RESOLUTION: Choice(options=["480p", "720p"]), + VideoParameter.FIRST_FRAME: ImageConstraint( supported_mime_types=BYTEPLUS_SUPPORTED_MIME_TYPES, ), - VideoGenerationParameter.LAST_FRAME: ImageConstraint( + VideoParameter.LAST_FRAME: ImageConstraint( supported_mime_types=BYTEPLUS_SUPPORTED_MIME_TYPES, ), }, diff --git a/src/celeste/modalities/videos/providers/byteplus/parameters.py b/src/celeste/modalities/videos/providers/byteplus/parameters.py new file mode 100644 index 00000000..ee409973 --- /dev/null +++ b/src/celeste/modalities/videos/providers/byteplus/parameters.py @@ -0,0 +1,71 @@ +"""BytePlus parameter mappers for videos.""" + +from celeste.parameters import ParameterMapper +from celeste.providers.byteplus.videos.parameters import ( + AspectRatioMapper as _AspectRatioMapper, +) +from celeste.providers.byteplus.videos.parameters import ( + DurationMapper as _DurationMapper, +) +from celeste.providers.byteplus.videos.parameters import ( + FirstFrameMapper as _FirstFrameMapper, +) +from celeste.providers.byteplus.videos.parameters import ( + LastFrameMapper as _LastFrameMapper, +) +from celeste.providers.byteplus.videos.parameters import ( + ReferenceImagesMapper as _ReferenceImagesMapper, +) +from celeste.providers.byteplus.videos.parameters import ( + ResolutionMapper as _ResolutionMapper, +) + +from ...parameters import VideoParameter + + +class AspectRatioMapper(_AspectRatioMapper): + """Map aspect_ratio to BytePlus's --ratio prompt flag.""" + + name = VideoParameter.ASPECT_RATIO + + +class ResolutionMapper(_ResolutionMapper): + """Map resolution to BytePlus's --resolution prompt flag.""" + + name = VideoParameter.RESOLUTION + + +class DurationMapper(_DurationMapper): + """Map duration to BytePlus's --duration prompt flag.""" + + name = VideoParameter.DURATION + + +class ReferenceImagesMapper(_ReferenceImagesMapper): + """Map reference_images to BytePlus content array.""" + + name = VideoParameter.REFERENCE_IMAGES + + +class FirstFrameMapper(_FirstFrameMapper): + """Map first_frame to BytePlus content array.""" + + name = VideoParameter.FIRST_FRAME + + +class LastFrameMapper(_LastFrameMapper): + """Map last_frame to BytePlus content array.""" + + name = VideoParameter.LAST_FRAME + + +BYTEPLUS_PARAMETER_MAPPERS: list[ParameterMapper] = [ + AspectRatioMapper(), + ResolutionMapper(), + DurationMapper(), + ReferenceImagesMapper(), + FirstFrameMapper(), + LastFrameMapper(), +] + +__all__ = ["BYTEPLUS_PARAMETER_MAPPERS"] diff --git a/src/celeste/modalities/videos/providers/google/__init__.py b/src/celeste/modalities/videos/providers/google/__init__.py new file mode 100644 index 00000000..a58e116c --- /dev/null +++ b/src/celeste/modalities/videos/providers/google/__init__.py @@ -0,0 +1,6 @@ +"""Google provider for videos modality.""" + +from .client import GoogleVideosClient +from .models import MODELS + +__all__ = ["MODELS", "GoogleVideosClient"] diff --git a/src/celeste/modalities/videos/providers/google/client.py b/src/celeste/modalities/videos/providers/google/client.py new file mode 100644 index 00000000..744bef7b --- /dev/null +++ b/src/celeste/modalities/videos/providers/google/client.py @@ -0,0 +1,81 @@ +"""Google videos client.""" + +from typing import Any, Unpack + +from celeste.artifacts import VideoArtifact +from celeste.parameters import ParameterMapper +from celeste.providers.google.veo import config +from celeste.providers.google.veo.client import GoogleVeoClient as GoogleVeoMixin + +from ...client import VideosClient +from ...io import VideoFinishReason, VideoInput, VideoOutput, VideoUsage +from ...parameters import VideoParameters +from .parameters import GOOGLE_PARAMETER_MAPPERS + + +class GoogleVideosClient(GoogleVeoMixin, VideosClient): + """Google client for video generation.""" + + @classmethod + def parameter_mappers(cls) -> list[ParameterMapper]: + return GOOGLE_PARAMETER_MAPPERS + + def _init_request(self, inputs: VideoInput) -> dict[str, Any]: + """Initialize request from Google Veo API format.""" + return { + "instances": [{"prompt": inputs.prompt}], + } + + async def generate( + self, + prompt: str, + **parameters: Unpack[VideoParameters], + ) -> VideoOutput: + """Generate videos from prompt.""" + inputs = VideoInput(prompt=prompt) + return await self._predict( + inputs, + endpoint=config.GoogleVeoEndpoint.CREATE_VIDEO, + **parameters, + ) + + def _parse_usage(self, response_data: dict[str, Any]) -> VideoUsage: + """Parse usage from response.""" + usage = super()._parse_usage(response_data) + return VideoUsage(**usage) + + def _parse_content( + self, + response_data: dict[str, Any], + **parameters: Unpack[VideoParameters], + ) -> VideoArtifact: + """Parse content from response.""" + video_data = super()._parse_content(response_data) + return VideoArtifact(url=video_data.get("uri")) + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> VideoFinishReason: + """Parse finish reason from response.""" + finish_reason = super()._parse_finish_reason(response_data) + return VideoFinishReason(reason=finish_reason.reason) + + async def download_content(self, artifact: VideoArtifact) -> VideoArtifact: + """Download video content from GCS URL. + + Args: + artifact: VideoArtifact with URL to download. + + Returns: + VideoArtifact with downloaded bytes data. + """ + if artifact.url is None: + msg = "Artifact has no URL to download" + raise ValueError(msg) + + video_bytes = await super().download_content(artifact.url) + return VideoArtifact( + data=video_bytes, + mime_type=artifact.mime_type, + ) + + +__all__ = ["GoogleVideosClient"] diff --git a/src/celeste/modalities/videos/providers/google/models.py b/src/celeste/modalities/videos/providers/google/models.py new file mode 100644 index 00000000..e9741d13 --- /dev/null +++ b/src/celeste/modalities/videos/providers/google/models.py @@ -0,0 +1,84 @@ +"""Google models for videos modality.""" + +from celeste.constraints import Choice, ImageConstraint, ImagesConstraint +from celeste.core import Modality, Operation, Provider +from celeste.mime_types import ImageMimeType +from celeste.models import Model + +from ...parameters import VideoParameter + +# Supported MIME types for all Veo models +VEO_SUPPORTED_MIME_TYPES = [ + ImageMimeType.JPEG, + ImageMimeType.PNG, + ImageMimeType.WEBP, +] + +MODELS: list[Model] = [ + Model( + id="veo-3.0-generate-001", + provider=Provider.GOOGLE, + display_name="Veo 3", + operations={Modality.VIDEOS: {Operation.GENERATE}}, + parameter_constraints={ + VideoParameter.ASPECT_RATIO: Choice(options=["16:9", "9:16"]), + VideoParameter.RESOLUTION: Choice(options=["720p"]), + VideoParameter.DURATION: Choice(options=[4, 6, 8]), + VideoParameter.FIRST_FRAME: ImageConstraint( + supported_mime_types=VEO_SUPPORTED_MIME_TYPES, + ), + }, + ), + Model( + id="veo-3.0-fast-generate-001", + provider=Provider.GOOGLE, + display_name="Veo 3 Fast", + operations={Modality.VIDEOS: {Operation.GENERATE}}, + parameter_constraints={ + VideoParameter.ASPECT_RATIO: Choice(options=["16:9", "9:16"]), + VideoParameter.RESOLUTION: Choice(options=["720p"]), + VideoParameter.DURATION: Choice(options=[4, 6, 8]), + VideoParameter.FIRST_FRAME: ImageConstraint( + supported_mime_types=VEO_SUPPORTED_MIME_TYPES + ), + }, + ), + Model( + id="veo-3.1-generate-preview", + provider=Provider.GOOGLE, + display_name="Veo 3.1 (Preview)", + operations={Modality.VIDEOS: {Operation.GENERATE}}, + parameter_constraints={ + VideoParameter.ASPECT_RATIO: Choice(options=["16:9", "9:16"]), + VideoParameter.RESOLUTION: Choice(options=["720p", "1080p", "4k"]), + VideoParameter.DURATION: Choice(options=[4, 6, 8]), + VideoParameter.REFERENCE_IMAGES: ImagesConstraint( + supported_mime_types=VEO_SUPPORTED_MIME_TYPES, + max_count=3, + ), + VideoParameter.FIRST_FRAME: ImageConstraint( + supported_mime_types=VEO_SUPPORTED_MIME_TYPES, + ), + VideoParameter.LAST_FRAME: ImageConstraint( + supported_mime_types=VEO_SUPPORTED_MIME_TYPES, + ), + }, + ), + Model( + id="veo-3.1-fast-generate-preview", + provider=Provider.GOOGLE, + display_name="Veo 3.1 Fast (Preview)", + operations={Modality.VIDEOS: {Operation.GENERATE}}, + parameter_constraints={ + VideoParameter.ASPECT_RATIO: Choice(options=["16:9", "9:16"]), + VideoParameter.RESOLUTION: Choice(options=["720p", "1080p", "4k"]), + VideoParameter.DURATION: Choice(options=[4, 6, 8]), + VideoParameter.FIRST_FRAME: ImageConstraint( + supported_mime_types=VEO_SUPPORTED_MIME_TYPES, + ), + VideoParameter.LAST_FRAME: ImageConstraint( + supported_mime_types=VEO_SUPPORTED_MIME_TYPES, + ), + }, + ), +] diff --git a/src/celeste/modalities/videos/providers/google/parameters.py b/src/celeste/modalities/videos/providers/google/parameters.py new file mode 100644 index 00000000..30ad7610 --- /dev/null +++ b/src/celeste/modalities/videos/providers/google/parameters.py @@ -0,0 +1,71 @@ +"""Google parameter mappers for videos.""" + +from celeste.parameters import ParameterMapper +from celeste.providers.google.veo.parameters import ( + AspectRatioMapper as _AspectRatioMapper, +) +from celeste.providers.google.veo.parameters import ( + DurationSecondsMapper as _DurationSecondsMapper, +) +from celeste.providers.google.veo.parameters import ( + FirstFrameMapper as _FirstFrameMapper, +) +from celeste.providers.google.veo.parameters import ( + LastFrameMapper as _LastFrameMapper, +) +from celeste.providers.google.veo.parameters import ( + ReferenceImagesMapper as _ReferenceImagesMapper, +) +from celeste.providers.google.veo.parameters import ( + ResolutionMapper as _ResolutionMapper, +) + +from ...parameters import VideoParameter + + +class AspectRatioMapper(_AspectRatioMapper): + """Map aspect_ratio to Google Veo's aspectRatio parameter.""" + + name = VideoParameter.ASPECT_RATIO + + +class ResolutionMapper(_ResolutionMapper): + """Map resolution to Google Veo's resolution parameter.""" + + name = VideoParameter.RESOLUTION + + +class DurationMapper(_DurationSecondsMapper): + """Map duration to Google Veo's durationSeconds parameter.""" + + name = VideoParameter.DURATION + + +class ReferenceImagesMapper(_ReferenceImagesMapper): + """Map reference_images to Google Veo's referenceImages parameter.""" + + name = VideoParameter.REFERENCE_IMAGES + + +class FirstFrameMapper(_FirstFrameMapper): + """Map first_frame to Google Veo's image parameter.""" + + name = VideoParameter.FIRST_FRAME + + +class LastFrameMapper(_LastFrameMapper): + """Map last_frame to Google Veo's lastFrame parameter.""" + + name = VideoParameter.LAST_FRAME + + +GOOGLE_PARAMETER_MAPPERS: list[ParameterMapper] = [ + AspectRatioMapper(), + ResolutionMapper(), + DurationMapper(), + ReferenceImagesMapper(), + FirstFrameMapper(), + LastFrameMapper(), +] + +__all__ = ["GOOGLE_PARAMETER_MAPPERS"] diff --git a/src/celeste/modalities/videos/providers/openai/__init__.py b/src/celeste/modalities/videos/providers/openai/__init__.py new file mode 100644 index 00000000..5ecb52d3 --- /dev/null +++ b/src/celeste/modalities/videos/providers/openai/__init__.py @@ -0,0 +1,6 @@ +"""OpenAI provider for videos modality.""" + +from .client import OpenAIVideosClient +from .models import MODELS + +__all__ = ["MODELS", "OpenAIVideosClient"] diff --git a/src/celeste/modalities/videos/providers/openai/client.py b/src/celeste/modalities/videos/providers/openai/client.py new file mode 100644 index 00000000..20863fcc --- /dev/null +++ b/src/celeste/modalities/videos/providers/openai/client.py @@ -0,0 +1,70 @@ +"""OpenAI videos client.""" + +import base64 +from typing import Any, Unpack + +from celeste.artifacts import VideoArtifact +from celeste.mime_types import VideoMimeType +from celeste.parameters import ParameterMapper +from celeste.providers.openai.videos import config +from celeste.providers.openai.videos.client import ( + OpenAIVideosClient as OpenAIVideosMixin, +) + +from ...client import VideosClient +from ...io import VideoFinishReason, VideoInput, VideoOutput, VideoUsage +from ...parameters import VideoParameters +from .parameters import OPENAI_PARAMETER_MAPPERS + + +class OpenAIVideosClient(OpenAIVideosMixin, VideosClient): + """OpenAI client for video generation.""" + + @classmethod + def parameter_mappers(cls) -> list[ParameterMapper]: + return OPENAI_PARAMETER_MAPPERS + + def _init_request(self, inputs: VideoInput) -> dict[str, Any]: + """Initialize request from OpenAI API format.""" + return { + "prompt": inputs.prompt, + } + + async def generate( + self, + prompt: str, + **parameters: Unpack[VideoParameters], + ) -> VideoOutput: + """Generate videos from prompt.""" + inputs = VideoInput(prompt=prompt) + return await self._predict( + inputs, + endpoint=config.OpenAIVideosEndpoint.CREATE_VIDEO, + **parameters, + ) + + def _parse_usage(self, response_data: dict[str, Any]) -> VideoUsage: + """Parse usage from response.""" + usage = super()._parse_usage(response_data) + return VideoUsage(**usage) + + def _parse_content( + self, + response_data: dict[str, Any], + **parameters: Unpack[VideoParameters], + ) -> VideoArtifact: + """Parse content from response.""" + video_data_b64 = super()._parse_content(response_data) + video_data = base64.b64decode(video_data_b64) + return VideoArtifact( + data=video_data, + mime_type=VideoMimeType.MP4, + ) + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> VideoFinishReason: + """Parse finish reason from response.""" + finish_reason = super()._parse_finish_reason(response_data) + return VideoFinishReason(reason=finish_reason.reason) + + +__all__ = ["OpenAIVideosClient"] diff --git a/src/celeste/modalities/videos/providers/openai/models.py b/src/celeste/modalities/videos/providers/openai/models.py new file mode 100644 index 00000000..b8e7ee9f --- /dev/null +++ b/src/celeste/modalities/videos/providers/openai/models.py @@ -0,0 +1,51 @@ +"""OpenAI models for videos modality.""" + +from celeste.constraints import Choice, ImageConstraint +from celeste.core import Modality, Operation, Provider +from celeste.mime_types import ImageMimeType +from celeste.models import Model + +from ...parameters import VideoParameter + +MODELS: list[Model] = [ + Model( + id="sora-2", + provider=Provider.OPENAI, + display_name="Sora 2", + operations={Modality.VIDEOS: {Operation.GENERATE}}, + parameter_constraints={ + VideoParameter.DURATION: Choice(options=["4", "8", "12"]), + VideoParameter.ASPECT_RATIO: Choice(options=["16:9", "9:16"]), + VideoParameter.RESOLUTION: Choice(options=["720p"]), + }, + ), + Model( + id="sora-2-pro", + provider=Provider.OPENAI, + display_name="Sora 2 Pro", + operations={Modality.VIDEOS: {Operation.GENERATE}}, + parameter_constraints={ + VideoParameter.DURATION: Choice(options=["4", "8", "12"]), + VideoParameter.ASPECT_RATIO: Choice(options=["16:9", "9:16"]), + VideoParameter.RESOLUTION: Choice(options=["720p"]), + VideoParameter.FIRST_FRAME: ImageConstraint( + supported_mime_types=[ + ImageMimeType.JPEG, + ImageMimeType.PNG, + ImageMimeType.WEBP, + ], + ), + }, + ), + Model( + id="sora-2-2025-12-08", + provider=Provider.OPENAI, + display_name="Sora 2 (December 2025)", + operations={Modality.VIDEOS: {Operation.GENERATE}}, + parameter_constraints={ + VideoParameter.DURATION: Choice(options=["4", "8", "12"]), + VideoParameter.ASPECT_RATIO: Choice(options=["16:9", "9:16"]), + VideoParameter.RESOLUTION: Choice(options=["720p"]), + }, + ), +] diff --git a/src/celeste/modalities/videos/providers/openai/parameters.py b/src/celeste/modalities/videos/providers/openai/parameters.py new file mode 100644 index 00000000..81ddb945 --- /dev/null +++ b/src/celeste/modalities/videos/providers/openai/parameters.py @@ -0,0 +1,116 @@ +"""OpenAI parameter mappers for videos. + +OpenAI Videos API uses a single `size` parameter (e.g., "1280x720") instead of +separate aspect_ratio and resolution parameters. This module provides unification +logic to transform Celeste's unified parameters to OpenAI's format. + +Mapping: +- aspect_ratio="16:9" + resolution="720p" → size="1280x720" +- aspect_ratio="9:16" + resolution="720p" → size="720x1280" +""" + +from typing import Any + +from celeste.models import Model +from celeste.parameters import ParameterMapper +from celeste.providers.openai.videos.parameters import ( + SecondsMapper as _SecondsMapper, +) +from celeste.providers.openai.videos.parameters import ( + SizeMapper as _SizeMapper, +) + +from ...parameters import VideoParameter + +# Resolution to height mapping +_RESOLUTION_HEIGHT: dict[str, int] = { + "720p": 720, + "1080p": 1080, + "4k": 2160, +} + +# Aspect ratio to (width_ratio, height_ratio) mapping +_ASPECT_RATIOS: dict[str, tuple[int, int]] = { + "16:9": (16, 9), + "9:16": (9, 16), + "1:1": (1, 1), + "4:3": (4, 3), + "3:4": (3, 4), +} + + +def _compute_size(aspect_ratio: str, resolution: str) -> str: + """Compute OpenAI size from aspect ratio and resolution. + + Args: + aspect_ratio: Aspect ratio like "16:9" or "9:16". + resolution: Resolution like "720p" or "1080p". + + Returns: + Size string like "1280x720". + """ + height = _RESOLUTION_HEIGHT.get(resolution, 720) + width_ratio, height_ratio = _ASPECT_RATIOS.get(aspect_ratio, (16, 9)) + + # Compute width from height and aspect ratio + width = int(height * width_ratio / height_ratio) + + return f"{width}x{height}" + + +class AspectRatioMapper(ParameterMapper): + """Store aspect_ratio for later combination with resolution.""" + + name = VideoParameter.ASPECT_RATIO + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Store aspect_ratio for ResolutionMapper to use.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["_aspect_ratio"] = validated_value + return request + + +class ResolutionMapper(_SizeMapper): + """Combine resolution with stored aspect_ratio to compute size.""" + + name = VideoParameter.RESOLUTION + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Compute size from resolution and stored aspect_ratio.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + aspect_ratio = request.pop("_aspect_ratio", "16:9") + size = _compute_size(aspect_ratio, str(validated_value)) + + # Delegate to provider's SizeMapper + return super().map(request, size, model) + + +class DurationMapper(_SecondsMapper): + """Map duration to OpenAI's seconds parameter.""" + + name = VideoParameter.DURATION + + +OPENAI_PARAMETER_MAPPERS: list[ParameterMapper] = [ + AspectRatioMapper(), + ResolutionMapper(), + DurationMapper(), +] + +__all__ = ["OPENAI_PARAMETER_MAPPERS"] diff --git a/src/celeste/models.py b/src/celeste/models.py index 2c0eee83..fc3d4080 100644 --- a/src/celeste/models.py +++ b/src/celeste/models.py @@ -3,8 +3,8 @@ from pydantic import BaseModel, Field, SerializeAsAny, computed_field from celeste.constraints import Constraint -from celeste.core import Capability, InputType, Provider -from celeste.io import get_constraint_input_type, get_required_input_types +from celeste.core import Capability, InputType, Modality, Operation, Provider +from celeste.io import get_constraint_input_type class Model(BaseModel): @@ -14,6 +14,7 @@ class Model(BaseModel): provider: Provider display_name: str capabilities: set[Capability] = Field(default_factory=set) + operations: dict[Modality, set[Operation]] = Field(default_factory=dict) parameter_constraints: dict[str, SerializeAsAny[Constraint]] = Field( default_factory=dict ) @@ -24,12 +25,6 @@ def supported_parameters(self) -> set[str]: """Compute supported parameter names from parameter_constraints.""" return set(self.parameter_constraints.keys()) - @computed_field # type: ignore[prop-decorator] - @property - def supported_input_types(self) -> dict[Capability, set[InputType]]: - """Input types supported per capability (derived from Input class fields).""" - return {cap: get_required_input_types(cap) for cap in self.capabilities} - @computed_field # type: ignore[prop-decorator] @property def optional_input_types(self) -> set[InputType]: @@ -46,17 +41,37 @@ def optional_input_types(self) -> set[InputType]: _models: dict[tuple[str, Provider], Model] = {} -def register_models(models: Model | list[Model], capability: Capability) -> None: +def register_models( + models: Model | list[Model], + capability: Capability | None = None, + *, + modality: Modality | None = None, + operation: Operation | None = None, +) -> 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. capability: The capability these models are being registered for. + .. deprecated:: + Use modality and operation parameters instead. + modality: The modality these models belong to (e.g., Modality.IMAGES). + operation: The operation these models support (e.g., Operation.GENERATE). Raises: ValueError: If display_name differs for duplicate (id, provider) pairs. """ + import warnings + + # Deprecation warning for capability parameter + if capability is not None: + warnings.warn( + "capability parameter is deprecated, use modality and operation instead", + DeprecationWarning, + stacklevel=2, + ) + if isinstance(models, Model): models = [models] @@ -83,10 +98,21 @@ def register_models(models: Model | list[Model], capability: Capability) -> None f"'{registered.display_name}' vs '{model.display_name}'" ) - # Update capabilities and constraints (single code path) - registered.capabilities.add(capability) + # Update constraints registered.parameter_constraints.update(model.parameter_constraints) + # Merge model's pre-existing operations (v1.0 path) + for mod, ops in model.operations.items(): + registered.operations.setdefault(mod, set()).update(ops) + + # Handle capability-based registration (backward compatibility) + if capability is not None: + registered.capabilities.add(capability) + + # Handle modality-based registration (new path) + if modality is not None and operation is not None: + registered.operations.setdefault(modality, set()).add(operation) + def get_model(model_id: str, provider: Provider | None = None) -> Model | None: """Get a registered model by ID, optionally filtered by provider. @@ -124,20 +150,20 @@ def get_model(model_id: str, provider: Provider | None = None) -> Model | None: def list_models( provider: Provider | None = None, capability: Capability | None = None, + modality: Modality | None = None, + operation: Operation | None = None, ) -> list[Model]: - """List all registered models, optionally filtered by provider and/or capability. + """List all registered models, optionally filtered by provider, capability, modality, and/or operation. 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. + provider: Optional provider filter. + capability: Optional capability filter (deprecated, use modality/operation). + modality: Optional modality filter. + operation: Optional operation filter (requires modality). Returns: List of Model instances matching the filters. """ - # Load packages lazily to avoid circular imports - from celeste.registry import _load_from_entry_points - - _load_from_entry_points() models = list(_models.values()) if provider is not None: @@ -146,6 +172,16 @@ def list_models( if capability is not None: models = [m for m in models if capability in m.capabilities] + if modality is not None and operation is not None: + # Filter by modality AND operation together + models = [m for m in models if operation in m.operations.get(modality, set())] + elif modality is not None: + models = [m for m in models if modality in m.operations] + elif operation is not None: + models = [ + m for m in models if any(operation in ops for ops in m.operations.values()) + ] + return models diff --git a/src/celeste/namespaces/__init__.py b/src/celeste/namespaces/__init__.py new file mode 100644 index 00000000..536bb5ae --- /dev/null +++ b/src/celeste/namespaces/__init__.py @@ -0,0 +1,44 @@ +"""Domain namespace API for Celeste SDK. + +Provides domain-first interface for AI operations: + + import celeste + + # Async (default) + result = await celeste.text.generate("Hello", model="gpt-4o") + + # Sync + result = celeste.text.sync.generate("Hello", model="gpt-4o") + + # Async streaming + async for chunk in celeste.text.stream.generate("Hello", model="gpt-4o"): + print(chunk.content, end="") + + # Sync streaming + for chunk in celeste.text.sync.stream.generate("Hello", model="gpt-4o"): + print(chunk.content, end="") +""" + +from celeste.namespaces.domains import ( + AudioNamespace, + ImagesNamespace, + TextNamespace, + VideosNamespace, +) + +# Module-level singletons +text = TextNamespace() +images = ImagesNamespace() +audio = AudioNamespace() +videos = VideosNamespace() + +__all__ = [ + "AudioNamespace", + "ImagesNamespace", + "TextNamespace", + "VideosNamespace", + "audio", + "images", + "text", + "videos", +] diff --git a/src/celeste/namespaces/domains.py b/src/celeste/namespaces/domains.py new file mode 100644 index 00000000..a2eb638b --- /dev/null +++ b/src/celeste/namespaces/domains.py @@ -0,0 +1,898 @@ +"""Domain-specific namespaces for Celeste SDK. + +Each namespace provides methods for operations on a specific domain (resource type). +The namespace routes to the appropriate modality client based on the operation. +""" + +from typing import Unpack + +from pydantic import SecretStr + +from celeste import create_client +from celeste.artifacts import ImageArtifact +from celeste.core import Modality, Operation, Provider +from celeste.modalities.audio.io import AudioOutput +from celeste.modalities.audio.parameters import AudioParameters +from celeste.modalities.audio.streaming import AudioStream +from celeste.modalities.embeddings.io import EmbeddingsOutput +from celeste.modalities.embeddings.parameters import EmbeddingsParameters +from celeste.modalities.images.io import ImageOutput +from celeste.modalities.images.parameters import ImageParameters +from celeste.modalities.images.streaming import ImagesStream +from celeste.modalities.text.io import TextOutput +from celeste.modalities.text.parameters import TextParameters +from celeste.modalities.text.streaming import TextStream +from celeste.modalities.videos.io import VideoOutput +from celeste.modalities.videos.parameters import VideoParameters +from celeste.types import AudioContent, ImageContent, VideoContent + + +class SyncStreamTextNamespace: + """celeste.text.sync.stream.* namespace.""" + + def generate( + self, + prompt: str, + *, + model: str, + provider: Provider | None = None, + api_key: str | SecretStr | None = None, + **params: Unpack[TextParameters], + ) -> TextStream: + """Sync streaming text generation.""" + client = create_client( + modality=Modality.TEXT, + operation=Operation.GENERATE, + model=model, + provider=provider, + api_key=api_key, + ) + return client.sync.stream.generate(prompt, **params) + + +class StreamTextNamespace: + """celeste.text.stream.* namespace.""" + + def generate( + self, + prompt: str, + *, + model: str, + provider: Provider | None = None, + api_key: str | SecretStr | None = None, + **params: Unpack[TextParameters], + ) -> TextStream: + """Async streaming text generation.""" + client = create_client( + modality=Modality.TEXT, + operation=Operation.GENERATE, + model=model, + provider=provider, + api_key=api_key, + ) + return client.stream.generate(prompt, **params) + + +class SyncTextNamespace: + """celeste.text.sync.* namespace.""" + + def generate( + self, + prompt: str, + *, + model: str, + provider: Provider | None = None, + api_key: str | SecretStr | None = None, + **params: Unpack[TextParameters], + ) -> TextOutput: + """Blocking text generation.""" + client = create_client( + modality=Modality.TEXT, + operation=Operation.GENERATE, + model=model, + provider=provider, + api_key=api_key, + ) + return client.sync.generate(prompt, **params) + + def embed( + self, + text: str | list[str], + *, + model: str, + provider: Provider | None = None, + api_key: str | SecretStr | None = None, + **params: Unpack[EmbeddingsParameters], + ) -> EmbeddingsOutput: + """Blocking embeddings generation.""" + client = create_client( + modality=Modality.EMBEDDINGS, + operation=Operation.EMBED, + model=model, + provider=provider, + api_key=api_key, + ) + return client.sync.embed(text, **params) + + @property + def stream(self) -> SyncStreamTextNamespace: + """Access sync streaming text operations.""" + return SyncStreamTextNamespace() + + +class TextNamespace: + """celeste.text.* namespace. + + Provides text generation and embedding operations. + """ + + async def generate( + self, + prompt: str, + *, + model: str, + provider: Provider | None = None, + api_key: str | SecretStr | None = None, + **parameters: Unpack[TextParameters], + ) -> TextOutput: + """Generate text from a prompt. + + Args: + prompt: The text prompt for generation. + model: Model ID to use (required). + provider: Optional provider override. + api_key: Optional API key override. + **parameters: Additional model parameters. + + Returns: + TextOutput with generated text. + """ + client = create_client( + modality=Modality.TEXT, + operation=Operation.GENERATE, + model=model, + provider=provider, + api_key=api_key, + ) + return await client.generate(prompt, **parameters) + + async def embed( + self, + text: str | list[str], + *, + model: str, + provider: Provider | None = None, + api_key: str | SecretStr | None = None, + **parameters: Unpack[EmbeddingsParameters], + ) -> EmbeddingsOutput: + """Generate embeddings from text. + + Args: + text: Text to embed. Single string or list of strings. + model: Model ID to use (required). + provider: Optional provider override. + api_key: Optional API key override. + **parameters: Additional model parameters. + + Returns: + EmbeddingsOutput with embedding vectors. + """ + client = create_client( + modality=Modality.EMBEDDINGS, + operation=Operation.EMBED, + model=model, + provider=provider, + api_key=api_key, + ) + return await client.embed(text, **parameters) + + @property + def sync(self) -> SyncTextNamespace: + """Access synchronous text operations.""" + return SyncTextNamespace() + + @property + def stream(self) -> StreamTextNamespace: + """Access streaming text operations.""" + return StreamTextNamespace() + + +class SyncStreamImagesNamespace: + """celeste.images.sync.stream.* namespace.""" + + def generate( + self, + prompt: str, + *, + model: str, + provider: Provider | None = None, + api_key: str | SecretStr | None = None, + **params: Unpack[ImageParameters], + ) -> ImagesStream: + """Sync streaming image generation.""" + client = create_client( + modality=Modality.IMAGES, + operation=Operation.GENERATE, + model=model, + provider=provider, + api_key=api_key, + ) + return client.sync.stream.generate(prompt, **params) + + def edit( + self, + image: ImageArtifact, + prompt: str, + *, + model: str, + provider: Provider | None = None, + api_key: str | SecretStr | None = None, + **params: Unpack[ImageParameters], + ) -> ImagesStream: + """Sync streaming image editing.""" + client = create_client( + modality=Modality.IMAGES, + operation=Operation.EDIT, + model=model, + provider=provider, + api_key=api_key, + ) + return client.sync.stream.edit(image, prompt, **params) + + def analyze( + self, + image: ImageContent, + prompt: str, + *, + model: str, + provider: Provider | None = None, + api_key: str | SecretStr | None = None, + **params: Unpack[TextParameters], + ) -> TextStream: + """Sync streaming image analysis.""" + client = create_client( + modality=Modality.TEXT, + operation=Operation.ANALYZE, + model=model, + provider=provider, + api_key=api_key, + ) + return client.sync.stream.analyze(prompt, image=image, **params) + + +class StreamImagesNamespace: + """celeste.images.stream.* namespace.""" + + def generate( + self, + prompt: str, + *, + model: str, + provider: Provider | None = None, + api_key: str | SecretStr | None = None, + **params: Unpack[ImageParameters], + ) -> ImagesStream: + """Async streaming image generation.""" + client = create_client( + modality=Modality.IMAGES, + operation=Operation.GENERATE, + model=model, + provider=provider, + api_key=api_key, + ) + return client.stream.generate(prompt, **params) + + def edit( + self, + image: ImageArtifact, + prompt: str, + *, + model: str, + provider: Provider | None = None, + api_key: str | SecretStr | None = None, + **params: Unpack[ImageParameters], + ) -> ImagesStream: + """Async streaming image editing.""" + client = create_client( + modality=Modality.IMAGES, + operation=Operation.EDIT, + model=model, + provider=provider, + api_key=api_key, + ) + return client.stream.edit(image, prompt, **params) + + def analyze( + self, + image: ImageContent, + prompt: str, + *, + model: str, + provider: Provider | None = None, + api_key: str | SecretStr | None = None, + **params: Unpack[TextParameters], + ) -> TextStream: + """Async streaming image analysis.""" + client = create_client( + modality=Modality.TEXT, + operation=Operation.ANALYZE, + model=model, + provider=provider, + api_key=api_key, + ) + return client.stream.analyze(prompt, image=image, **params) + + +class SyncImagesNamespace: + """celeste.images.sync.* namespace.""" + + def generate( + self, + prompt: str, + *, + model: str, + provider: Provider | None = None, + api_key: str | SecretStr | None = None, + **params: Unpack[ImageParameters], + ) -> ImageOutput: + """Blocking image generation.""" + client = create_client( + modality=Modality.IMAGES, + operation=Operation.GENERATE, + model=model, + provider=provider, + api_key=api_key, + ) + return client.sync.generate(prompt, **params) + + def edit( + self, + image: ImageArtifact, + prompt: str, + *, + model: str, + provider: Provider | None = None, + api_key: str | SecretStr | None = None, + **params: Unpack[ImageParameters], + ) -> ImageOutput: + """Blocking image editing.""" + client = create_client( + modality=Modality.IMAGES, + operation=Operation.EDIT, + model=model, + provider=provider, + api_key=api_key, + ) + return client.sync.edit(image, prompt, **params) + + def analyze( + self, + image: ImageContent, + prompt: str, + *, + model: str, + provider: Provider | None = None, + api_key: str | SecretStr | None = None, + **params: Unpack[TextParameters], + ) -> TextOutput: + """Blocking image analysis.""" + client = create_client( + modality=Modality.TEXT, + operation=Operation.ANALYZE, + model=model, + provider=provider, + api_key=api_key, + ) + return client.sync.analyze(prompt, image=image, **params) + + @property + def stream(self) -> SyncStreamImagesNamespace: + """Access sync streaming image operations.""" + return SyncStreamImagesNamespace() + + +class ImagesNamespace: + """celeste.images.* namespace. + + Provides image generation, editing, and analysis operations. + """ + + async def generate( + self, + prompt: str, + *, + model: str, + provider: Provider | None = None, + api_key: str | SecretStr | None = None, + **parameters: Unpack[ImageParameters], + ) -> ImageOutput: + """Generate images from a prompt. + + Args: + prompt: The text prompt for image generation. + model: Model ID to use (required). + provider: Optional provider override. + api_key: Optional API key override. + **parameters: Additional model parameters. + + Returns: + ImageOutput with generated image. + """ + client = create_client( + modality=Modality.IMAGES, + operation=Operation.GENERATE, + model=model, + provider=provider, + api_key=api_key, + ) + return await client.generate(prompt, **parameters) + + async def edit( + self, + image: ImageArtifact, + prompt: str, + *, + model: str, + provider: Provider | None = None, + api_key: str | SecretStr | None = None, + **parameters: Unpack[ImageParameters], + ) -> ImageOutput: + """Edit an image with a prompt. + + Args: + image: The image to edit. + prompt: Instructions for editing. + model: Model ID to use (required). + provider: Optional provider override. + api_key: Optional API key override. + **parameters: Additional model parameters. + + Returns: + ImageOutput with edited image. + """ + client = create_client( + modality=Modality.IMAGES, + operation=Operation.EDIT, + model=model, + provider=provider, + api_key=api_key, + ) + return await client.edit(image, prompt, **parameters) + + async def analyze( + self, + image: ImageContent, + prompt: str, + *, + model: str, + provider: Provider | None = None, + api_key: str | SecretStr | None = None, + **parameters: Unpack[TextParameters], + ) -> TextOutput: + """Analyze images and return text description. + + Args: + image: Image or list of images to analyze. + prompt: Question or instruction about the image. + model: Model ID to use (required). + provider: Optional provider override. + api_key: Optional API key override. + **parameters: Additional model parameters. + + Returns: + TextOutput with analysis result. + """ + client = create_client( + modality=Modality.TEXT, + operation=Operation.ANALYZE, + model=model, + provider=provider, + api_key=api_key, + ) + return await client.analyze(prompt, image=image, **parameters) + + @property + def sync(self) -> SyncImagesNamespace: + """Access synchronous image operations.""" + return SyncImagesNamespace() + + @property + def stream(self) -> StreamImagesNamespace: + """Access streaming image operations.""" + return StreamImagesNamespace() + + +class SyncStreamAudioNamespace: + """celeste.audio.sync.stream.* namespace.""" + + def speak( + self, + text: str, + *, + model: str, + provider: Provider | None = None, + api_key: str | SecretStr | None = None, + **params: Unpack[AudioParameters], + ) -> AudioStream: + """Sync streaming text-to-speech.""" + client = create_client( + modality=Modality.AUDIO, + operation=Operation.SPEAK, + model=model, + provider=provider, + api_key=api_key, + ) + return client.sync.stream.speak(text, **params) + + def analyze( + self, + audio: AudioContent, + prompt: str, + *, + model: str, + provider: Provider | None = None, + api_key: str | SecretStr | None = None, + **params: Unpack[TextParameters], + ) -> TextStream: + """Sync streaming audio analysis.""" + client = create_client( + modality=Modality.TEXT, + operation=Operation.ANALYZE, + model=model, + provider=provider, + api_key=api_key, + ) + return client.sync.stream.analyze(prompt, audio=audio, **params) + + +class StreamAudioNamespace: + """celeste.audio.stream.* namespace.""" + + def speak( + self, + text: str, + *, + model: str, + provider: Provider | None = None, + api_key: str | SecretStr | None = None, + **params: Unpack[AudioParameters], + ) -> AudioStream: + """Async streaming text-to-speech.""" + client = create_client( + modality=Modality.AUDIO, + operation=Operation.SPEAK, + model=model, + provider=provider, + api_key=api_key, + ) + return client.stream.speak(text, **params) + + def analyze( + self, + audio: AudioContent, + prompt: str, + *, + model: str, + provider: Provider | None = None, + api_key: str | SecretStr | None = None, + **params: Unpack[TextParameters], + ) -> TextStream: + """Async streaming audio analysis.""" + client = create_client( + modality=Modality.TEXT, + operation=Operation.ANALYZE, + model=model, + provider=provider, + api_key=api_key, + ) + return client.stream.analyze(prompt, audio=audio, **params) + + +class SyncAudioNamespace: + """celeste.audio.sync.* namespace.""" + + def speak( + self, + text: str, + *, + model: str, + provider: Provider | None = None, + api_key: str | SecretStr | None = None, + **params: Unpack[AudioParameters], + ) -> AudioOutput: + """Blocking text-to-speech.""" + client = create_client( + modality=Modality.AUDIO, + operation=Operation.SPEAK, + model=model, + provider=provider, + api_key=api_key, + ) + return client.sync.speak(text, **params) + + def analyze( + self, + audio: AudioContent, + prompt: str, + *, + model: str, + provider: Provider | None = None, + api_key: str | SecretStr | None = None, + **params: Unpack[TextParameters], + ) -> TextOutput: + """Blocking audio analysis.""" + client = create_client( + modality=Modality.TEXT, + operation=Operation.ANALYZE, + model=model, + provider=provider, + api_key=api_key, + ) + return client.sync.analyze(prompt, audio=audio, **params) + + @property + def stream(self) -> SyncStreamAudioNamespace: + """Access sync streaming audio operations.""" + return SyncStreamAudioNamespace() + + +class AudioNamespace: + """celeste.audio.* namespace. + + Provides text-to-speech and audio analysis operations. + """ + + async def speak( + self, + text: str, + *, + model: str, + provider: Provider | None = None, + api_key: str | SecretStr | None = None, + **parameters: Unpack[AudioParameters], + ) -> AudioOutput: + """Convert text to speech. + + Args: + text: Text to convert to speech. + model: Model ID to use (required). + provider: Optional provider override. + api_key: Optional API key override. + **parameters: Additional model parameters (e.g., voice). + + Returns: + AudioOutput with generated audio. + """ + client = create_client( + modality=Modality.AUDIO, + operation=Operation.SPEAK, + model=model, + provider=provider, + api_key=api_key, + ) + return await client.speak(text, **parameters) + + async def analyze( + self, + audio: AudioContent, + prompt: str, + *, + model: str, + provider: Provider | None = None, + api_key: str | SecretStr | None = None, + **parameters: Unpack[TextParameters], + ) -> TextOutput: + """Analyze audio and return text transcription/description. + + Args: + audio: Audio or list of audio files to analyze. + prompt: Question or instruction about the audio. + model: Model ID to use (required). + provider: Optional provider override. + api_key: Optional API key override. + **parameters: Additional model parameters. + + Returns: + TextOutput with analysis/transcription result. + """ + client = create_client( + modality=Modality.TEXT, + operation=Operation.ANALYZE, + model=model, + provider=provider, + api_key=api_key, + ) + return await client.analyze(prompt, audio=audio, **parameters) + + @property + def sync(self) -> SyncAudioNamespace: + """Access synchronous audio operations.""" + return SyncAudioNamespace() + + @property + def stream(self) -> StreamAudioNamespace: + """Access streaming audio operations.""" + return StreamAudioNamespace() + + +class SyncStreamVideosNamespace: + """celeste.videos.sync.stream.* namespace.""" + + def analyze( + self, + video: VideoContent, + prompt: str, + *, + model: str, + provider: Provider | None = None, + api_key: str | SecretStr | None = None, + **params: Unpack[TextParameters], + ) -> TextStream: + """Sync streaming video analysis.""" + client = create_client( + modality=Modality.TEXT, + operation=Operation.ANALYZE, + model=model, + provider=provider, + api_key=api_key, + ) + return client.sync.stream.analyze(prompt, video=video, **params) + + +class StreamVideosNamespace: + """celeste.videos.stream.* namespace.""" + + def analyze( + self, + video: VideoContent, + prompt: str, + *, + model: str, + provider: Provider | None = None, + api_key: str | SecretStr | None = None, + **params: Unpack[TextParameters], + ) -> TextStream: + """Async streaming video analysis.""" + client = create_client( + modality=Modality.TEXT, + operation=Operation.ANALYZE, + model=model, + provider=provider, + api_key=api_key, + ) + return client.stream.analyze(prompt, video=video, **params) + + +class SyncVideosNamespace: + """celeste.videos.sync.* namespace.""" + + def generate( + self, + prompt: str, + *, + model: str, + provider: Provider | None = None, + api_key: str | SecretStr | None = None, + **params: Unpack[VideoParameters], + ) -> VideoOutput: + """Blocking video generation.""" + client = create_client( + modality=Modality.VIDEOS, + operation=Operation.GENERATE, + model=model, + provider=provider, + api_key=api_key, + ) + return client.sync.generate(prompt, **params) + + def analyze( + self, + video: VideoContent, + prompt: str, + *, + model: str, + provider: Provider | None = None, + api_key: str | SecretStr | None = None, + **params: Unpack[TextParameters], + ) -> TextOutput: + """Blocking video analysis.""" + client = create_client( + modality=Modality.TEXT, + operation=Operation.ANALYZE, + model=model, + provider=provider, + api_key=api_key, + ) + return client.sync.analyze(prompt, video=video, **params) + + @property + def stream(self) -> SyncStreamVideosNamespace: + """Access sync streaming video operations.""" + return SyncStreamVideosNamespace() + + +class VideosNamespace: + """celeste.videos.* namespace. + + Provides video generation and analysis operations. + """ + + async def generate( + self, + prompt: str, + *, + model: str, + provider: Provider | None = None, + api_key: str | SecretStr | None = None, + **parameters: Unpack[VideoParameters], + ) -> VideoOutput: + """Generate video from a prompt. + + Args: + prompt: The text prompt for video generation. + model: Model ID to use (required). + provider: Optional provider override. + api_key: Optional API key override. + **parameters: Additional model parameters. + + Returns: + VideoOutput with generated video. + """ + client = create_client( + modality=Modality.VIDEOS, + operation=Operation.GENERATE, + model=model, + provider=provider, + api_key=api_key, + ) + return await client.generate(prompt, **parameters) + + async def analyze( + self, + video: VideoContent, + prompt: str, + *, + model: str, + provider: Provider | None = None, + api_key: str | SecretStr | None = None, + **parameters: Unpack[TextParameters], + ) -> TextOutput: + """Analyze video and return text description. + + Args: + video: Video or list of videos to analyze. + prompt: Question or instruction about the video. + model: Model ID to use (required). + provider: Optional provider override. + api_key: Optional API key override. + **parameters: Additional model parameters. + + Returns: + TextOutput with analysis result. + """ + client = create_client( + modality=Modality.TEXT, + operation=Operation.ANALYZE, + model=model, + provider=provider, + api_key=api_key, + ) + return await client.analyze(prompt, video=video, **parameters) + + @property + def sync(self) -> SyncVideosNamespace: + """Access synchronous video operations.""" + return SyncVideosNamespace() + + @property + def stream(self) -> StreamVideosNamespace: + """Access streaming video operations.""" + return StreamVideosNamespace() + + +__all__ = [ + "AudioNamespace", + "ImagesNamespace", + "TextNamespace", + "VideosNamespace", +] diff --git a/src/celeste/parameters.py b/src/celeste/parameters.py index d904bdc8..f195be9e 100644 --- a/src/celeste/parameters.py +++ b/src/celeste/parameters.py @@ -6,7 +6,7 @@ from celeste.exceptions import UnsupportedParameterError from celeste.models import Model -from celeste.types import StructuredOutput +from celeste.types import TextContent class Parameters(TypedDict, total=False): @@ -33,9 +33,7 @@ def map(self, request: dict[str, Any], value: Any, model: Model) -> dict[str, An """ ... - def parse_output( - self, content: StructuredOutput, value: object | None - ) -> StructuredOutput: + def parse_output(self, content: TextContent, value: object | None) -> TextContent: """Optionally transform parsed content based on parameter value (default: return unchanged).""" return content diff --git a/src/celeste/providers/__init__.py b/src/celeste/providers/__init__.py new file mode 100644 index 00000000..d878d4c9 --- /dev/null +++ b/src/celeste/providers/__init__.py @@ -0,0 +1,33 @@ +"""Celeste providers.""" + +from celeste.providers import ( + anthropic, + bfl, + byteplus, + cohere, + deepseek, + elevenlabs, + google, + gradium, + groq, + mistral, + moonshot, + openai, + xai, +) + +__all__ = [ + "anthropic", + "bfl", + "byteplus", + "cohere", + "deepseek", + "elevenlabs", + "google", + "gradium", + "groq", + "mistral", + "moonshot", + "openai", + "xai", +] diff --git a/src/celeste/providers/anthropic/__init__.py b/src/celeste/providers/anthropic/__init__.py new file mode 100644 index 00000000..4965cc98 --- /dev/null +++ b/src/celeste/providers/anthropic/__init__.py @@ -0,0 +1,12 @@ +"""Anthropic provider package for Celeste AI.""" + +from celeste.core import Provider +from celeste.credentials import register_auth + +# Register Anthropic auth config when package is imported +register_auth( # nosec B106 - env var name, not hardcoded password + provider=Provider.ANTHROPIC, + secret_name="ANTHROPIC_API_KEY", + header="x-api-key", + prefix="", +) diff --git a/packages/providers/anthropic/src/celeste_anthropic/messages/__init__.py b/src/celeste/providers/anthropic/messages/__init__.py similarity index 100% rename from packages/providers/anthropic/src/celeste_anthropic/messages/__init__.py rename to src/celeste/providers/anthropic/messages/__init__.py diff --git a/packages/providers/anthropic/src/celeste_anthropic/messages/client.py b/src/celeste/providers/anthropic/messages/client.py similarity index 76% rename from packages/providers/anthropic/src/celeste_anthropic/messages/client.py rename to src/celeste/providers/anthropic/messages/client.py index cab5b1b7..a80c377e 100644 --- a/packages/providers/anthropic/src/celeste_anthropic/messages/client.py +++ b/src/celeste/providers/anthropic/messages/client.py @@ -1,10 +1,8 @@ -"""Anthropic Messages API client with shared implementation.""" +"""Anthropic Messages API client mixin.""" from collections.abc import AsyncIterator from typing import Any -import httpx - from celeste.client import APIMixin from celeste.core import UsageField from celeste.io import FinishReason @@ -34,21 +32,6 @@ def _parse_content(self, response_data, **parameters): return "" """ - def _build_request( - self, - inputs: Any, - **parameters: Any, - ) -> Any: - """Build request with Anthropic-specific defaults.""" - request = super()._build_request(inputs, **parameters) - request["model"] = self.model.id - - # Apply max_tokens default if not set (Anthropic requires it) - if "max_tokens" not in request: - request["max_tokens"] = config.DEFAULT_MAX_TOKENS - - return request - def _build_headers(self, request_body: dict[str, Any]) -> dict[str, str]: """Build headers with beta features extracted from request.""" beta_features: list[str] = request_body.pop("_beta_features", []) @@ -68,37 +51,73 @@ def _build_headers(self, request_body: dict[str, Any]) -> dict[str, str]: return headers + def _build_request( + self, + inputs: Any, + extra_body: dict[str, Any] | None = None, + streaming: bool = False, + **parameters: Any, + ) -> dict[str, Any]: + """Build request with model ID and streaming flag.""" + request_body = super()._build_request( + inputs, extra_body=extra_body, streaming=streaming, **parameters + ) + request_body["model"] = self.model.id + if streaming: + request_body["stream"] = True + return request_body + async def _make_request( self, request_body: dict[str, Any], + *, + endpoint: str | None = None, **parameters: Any, - ) -> httpx.Response: + ) -> dict[str, Any]: """Make HTTP request to Anthropic Messages API endpoint.""" + # Apply max_tokens default if not set (Anthropic requires it) + if "max_tokens" not in request_body: + request_body["max_tokens"] = config.DEFAULT_MAX_TOKENS + headers = self._build_headers(request_body) - return await self.http_client.post( - f"{config.BASE_URL}{config.AnthropicMessagesEndpoint.CREATE_MESSAGE}", + if endpoint is None: + endpoint = config.AnthropicMessagesEndpoint.CREATE_MESSAGE + + response = await self.http_client.post( + f"{config.BASE_URL}{endpoint}", headers=headers, json_body=request_body, ) + self._handle_error_response(response) + data: dict[str, Any] = response.json() + return data def _make_stream_request( self, request_body: dict[str, Any], + *, + endpoint: str | None = None, **parameters: Any, ) -> AsyncIterator[dict[str, Any]]: """Make streaming request to Anthropic Messages API endpoint.""" - request_body["stream"] = True + # Apply max_tokens default if not set (Anthropic requires it) + if "max_tokens" not in request_body: + request_body["max_tokens"] = config.DEFAULT_MAX_TOKENS + headers = self._build_headers(request_body) + if endpoint is None: + endpoint = config.AnthropicMessagesEndpoint.CREATE_MESSAGE + return self.http_client.stream_post( - f"{config.BASE_URL}{config.AnthropicMessagesEndpoint.CREATE_MESSAGE}", + f"{config.BASE_URL}{endpoint}", headers=headers, json_body=request_body, ) @staticmethod - def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | None]: + def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | float | None]: """Map Anthropic usage fields to unified names. Shared by client and streaming across all capabilities. @@ -118,7 +137,9 @@ def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | None]: UsageField.CACHED_TOKENS: cached_tokens, } - def _parse_usage(self, response_data: dict[str, Any]) -> dict[str, int | None]: + def _parse_usage( + self, response_data: dict[str, Any] + ) -> dict[str, int | float | None]: """Extract usage data from Messages API response.""" usage_data = response_data.get("usage", {}) return AnthropicMessagesClient.map_usage_fields(usage_data) @@ -143,7 +164,7 @@ def _parse_finish_reason(self, response_data: dict[str, Any]) -> FinishReason: return FinishReason(reason=stop_reason) def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: - """Build metadata dictionary, filtering out content field.""" + """Build metadata dictionary, filtering out content fields.""" content_fields = {"content"} filtered_data = { k: v for k, v in response_data.items() if k not in content_fields diff --git a/packages/providers/anthropic/src/celeste_anthropic/messages/config.py b/src/celeste/providers/anthropic/messages/config.py similarity index 96% rename from packages/providers/anthropic/src/celeste_anthropic/messages/config.py rename to src/celeste/providers/anthropic/messages/config.py index de7ed7e9..3199bed8 100644 --- a/packages/providers/anthropic/src/celeste_anthropic/messages/config.py +++ b/src/celeste/providers/anthropic/messages/config.py @@ -4,7 +4,7 @@ class AnthropicMessagesEndpoint(StrEnum): - """Endpoints for Messages API.""" + """Endpoints for Anthropic Messages API.""" CREATE_MESSAGE = "/v1/messages" COUNT_MESSAGE_TOKENS = "/v1/messages/count_tokens" diff --git a/packages/providers/anthropic/src/celeste_anthropic/messages/parameters.py b/src/celeste/providers/anthropic/messages/parameters.py similarity index 86% rename from packages/providers/anthropic/src/celeste_anthropic/messages/parameters.py rename to src/celeste/providers/anthropic/messages/parameters.py index e948254f..da5d2cee 100644 --- a/packages/providers/anthropic/src/celeste_anthropic/messages/parameters.py +++ b/src/celeste/providers/anthropic/messages/parameters.py @@ -8,7 +8,7 @@ from celeste.models import Model from celeste.parameters import ParameterMapper from celeste.structured_outputs import StrictJsonSchemaGenerator -from celeste.types import StructuredOutput +from celeste.types import TextContent class TemperatureMapper(ParameterMapper): @@ -127,8 +127,31 @@ def map( return request -class OutputSchemaMapper(ParameterMapper): - """Map output_schema to Anthropic native structured outputs (output_format). +class WebSearchMapper(ParameterMapper): + """Map web_search to Anthropic tools field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform web_search into provider request.""" + validated_value = self._validate_value(value, model) + if not validated_value: + return request + + request.setdefault("tools", []).append( + { + "type": "web_search_20250305", + "name": "web_search", + } + ) + return request + + +class OutputFormatMapper(ParameterMapper): + """Map output_schema to Anthropic output_format field. Handles both single BaseModel and list[BaseModel] types. Anthropic supports top-level arrays, $ref, and $defs natively. @@ -170,9 +193,7 @@ def map( return request - def parse_output( - self, content: StructuredOutput, value: object | None - ) -> StructuredOutput: + def parse_output(self, content: TextContent, value: object | None) -> TextContent: """Parse JSON to BaseModel using Pydantic's TypeAdapter.""" if value is None: return content if isinstance(content, str) else json.dumps(content) @@ -196,10 +217,11 @@ def parse_output( __all__ = [ "MaxTokensMapper", - "OutputSchemaMapper", + "OutputFormatMapper", "StopSequencesMapper", "TemperatureMapper", "ThinkingMapper", "TopKMapper", "TopPMapper", + "WebSearchMapper", ] diff --git a/src/celeste/providers/anthropic/messages/streaming.py b/src/celeste/providers/anthropic/messages/streaming.py new file mode 100644 index 00000000..c9315386 --- /dev/null +++ b/src/celeste/providers/anthropic/messages/streaming.py @@ -0,0 +1,76 @@ +"""Anthropic Messages SSE parsing for streaming.""" + +from typing import Any + +from celeste.io import FinishReason + +from .client import AnthropicMessagesClient + + +class AnthropicMessagesStream: + """Mixin for Messages API SSE parsing. + + Provides shared implementation for streaming parsing (provider API level): + - _parse_chunk_content(event_data) - Extract content from SSE event + - _parse_chunk_usage(event_data) - Extract and normalize usage from SSE event + - _parse_chunk_finish_reason(event_data) - Extract finish reason from SSE event + + Modality streams call super() methods which resolve to this via MRO. + """ + + def _parse_chunk_content(self, event_data: dict[str, Any]) -> str | None: + """Extract content from SSE event. + + Returns content string if present, None otherwise. + """ + event_type = event_data.get("type") + + if event_type == "content_block_delta": + delta = event_data.get("delta", {}) + if delta.get("type") == "text_delta": + return delta.get("text") + + return None + + def _parse_chunk_usage( + self, event_data: dict[str, Any] + ) -> dict[str, int | float | None] | None: + """Extract and normalize usage from SSE event. + + Returns normalized usage dict if present, None otherwise. + """ + event_type = event_data.get("type") + + if event_type in ("message_delta", "message_stop"): + usage_data = event_data.get("usage") + if usage_data: + return AnthropicMessagesClient.map_usage_fields(usage_data) + + return None + + def _parse_chunk_finish_reason( + self, event_data: dict[str, Any] + ) -> FinishReason | None: + """Extract finish reason from SSE event. + + Returns FinishReason if present, None otherwise. + """ + event_type = event_data.get("type") + + if event_type == "message_delta": + delta = event_data.get("delta", {}) + stop_reason = delta.get("stop_reason") + if stop_reason: + return FinishReason(reason=stop_reason) + + return None + + def _build_stream_metadata( + self, raw_events: list[dict[str, Any]] + ) -> dict[str, Any]: + """Filter content-only events for size efficiency (content is in Output.content).""" + filtered = [e for e in raw_events if e.get("type") != "content_block_delta"] + return super()._build_stream_metadata(filtered) # type: ignore[misc] + + +__all__ = ["AnthropicMessagesStream"] diff --git a/packages/providers/bfl/src/celeste_bfl/py.typed b/src/celeste/providers/anthropic/py.typed similarity index 100% rename from packages/providers/bfl/src/celeste_bfl/py.typed rename to src/celeste/providers/anthropic/py.typed diff --git a/src/celeste/providers/api_references.md b/src/celeste/providers/api_references.md new file mode 100644 index 00000000..743cf017 --- /dev/null +++ b/src/celeste/providers/api_references.md @@ -0,0 +1,43 @@ +# API Reference Links + +This document contains the official API reference documentation links for all provider APIs used in Celeste. + +## Provider - API Reference Links + +| Provider | API | API Reference Link | User Validation | +|----------|-----|---------------------|-----------------| +| **Google** | GenerateContent | [GenerateContent API Reference](https://ai.google.dev/api/generate-content) | ✅ | +| **Google** | Embeddings | [Embeddings API Reference](https://ai.google.dev/api/embeddings) | ✅ | +| **Google** | Imagen | [Imagen API Reference](https://ai.google.dev/gemini-api/docs/imagen) | ✅ | +| **Google** | Cloud TTS | [Cloud Text-to-Speech API Reference](https://docs.cloud.google.com/text-to-speech/docs/reference/rest) | ✅ | +| **Google** | Veo | [Veo API Reference](https://ai.google.dev/gemini-api/docs/video) | ✅ | +| **OpenAI** | Responses | [Responses API Reference](https://platform.openai.com/docs/api-reference/responses) | ✅ | +| **OpenAI** | Images | [Images API Reference](https://platform.openai.com/docs/api-reference/images) | ✅ | +| **OpenAI** | Audio | [Audio API Reference](https://platform.openai.com/docs/api-reference/audio) | ✅ | +| **OpenAI** | Videos | [Videos API Reference](https://platform.openai.com/docs/api-reference/videos) | ✅ | +| **Anthropic** | Messages | [Messages API Reference](https://docs.anthropic.com/claude/reference/messages) | ✅ | +| **Cohere** | Chat | [Chat API Reference](https://docs.cohere.com/reference/chat) | ✅ | +| **DeepSeek** | Chat | [Chat API Reference](https://api-docs.deepseek.com/api/create-chat-completion) | ✅ | +| **Groq** | Chat | [Chat API Reference](https://console.groq.com/docs/api-reference#chat-create) | ✅ | +| **Mistral** | Chat | [Chat API Reference](https://docs.mistral.ai/api/) | ✅ | +| **Moonshot** | Chat | [Chat API Reference](https://platform.moonshot.ai/docs/api/chat) | ✅ | +| **XAI** | Responses | [Responses API Reference](https://docs.x.ai/docs/api-reference#create-new-response) | ✅ | +| **ElevenLabs** | Text-to-Speech | [Text-to-Speech API Reference](https://elevenlabs.io/docs/api-reference/text-to-speech) | ✅ | +| **BytePlus** | Images | [Images API Reference](https://docs.byteplus.com/en/docs/ModelArk/1541523) | ✅ | +| **BytePlus** | Videos | [Videos API Reference](https://docs.byteplus.com/en/docs/ModelArk/1520757) | ✅ | +| **BFL** | Images | [Images API Reference](https://docs.bfl.ml/flux_2/flux2_text_to_image) | ✅ | +| **Gradium** | TTS | [TTS API Reference](https://gradium.ai/api_docs.html) | ✅ | + +## Summary + +- **Total APIs:** 21 provider-API combinations +- **All validated:** ✅ Complete +- **Naming convention:** Method names only (Provider column provides context) +- **Link strategy:** Specific method/endpoint pages when available, general API reference pages otherwise + +## Notes + +- All API reference links have been validated and are ready to use +- Method names are used in the API column (e.g., "GenerateContent", "Chat", "Responses") +- The Provider column provides context for which provider's API is being referenced +- Links point to specific method/endpoint documentation when available for better developer experience diff --git a/src/celeste/providers/bfl/__init__.py b/src/celeste/providers/bfl/__init__.py new file mode 100644 index 00000000..64fb34c5 --- /dev/null +++ b/src/celeste/providers/bfl/__init__.py @@ -0,0 +1,11 @@ +"""BFL (Black Forest Labs) 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.BFL, + secret_name="BFL_API_KEY", + header="x-key", + prefix="", +) diff --git a/packages/providers/bfl/src/celeste_bfl/images/__init__.py b/src/celeste/providers/bfl/images/__init__.py similarity index 100% rename from packages/providers/bfl/src/celeste_bfl/images/__init__.py rename to src/celeste/providers/bfl/images/__init__.py diff --git a/packages/providers/bfl/src/celeste_bfl/images/client.py b/src/celeste/providers/bfl/images/client.py similarity index 74% rename from packages/providers/bfl/src/celeste_bfl/images/client.py rename to src/celeste/providers/bfl/images/client.py index c9bff5cf..eae6660a 100644 --- a/packages/providers/bfl/src/celeste_bfl/images/client.py +++ b/src/celeste/providers/bfl/images/client.py @@ -1,13 +1,13 @@ -"""BFL Images API client with shared implementation.""" +"""BFL Images API client mixin.""" import asyncio import time +from collections.abc import AsyncIterator from typing import Any -import httpx - from celeste.client import APIMixin from celeste.core import UsageField +from celeste.exceptions import StreamingNotSupportedError from celeste.io import FinishReason from celeste.mime_types import ApplicationMimeType @@ -36,8 +36,10 @@ def _parse_content(self, response_data, **parameters): async def _make_request( self, request_body: dict[str, Any], + *, + endpoint: str | None = None, **parameters: Any, - ) -> httpx.Response: + ) -> dict[str, Any]: """Make HTTP request with async polling for BFL image generation. Handles the complete async polling workflow: @@ -52,7 +54,9 @@ async def _make_request( "Accept": ApplicationMimeType.JSON, } - endpoint = config.BFLImagesEndpoint.CREATE_IMAGE.format(model_id=self.model.id) + if endpoint is None: + endpoint = config.BFLImagesEndpoint.CREATE_IMAGE + endpoint = endpoint.format(model_id=self.model.id) # Phase 1: Submit job submit_response = await self.http_client.post( @@ -61,9 +65,7 @@ async def _make_request( json_body=request_body, ) - if submit_response.status_code != 200: - return submit_response - + self._handle_error_response(submit_response) submit_data = submit_response.json() polling_url = submit_data.get("polling_url") @@ -89,38 +91,39 @@ async def _make_request( headers=poll_headers, ) - if poll_response.status_code != 200: - return poll_response - + self._handle_error_response(poll_response) poll_data = poll_response.json() status = poll_data.get("status") if status == "Ready": # Merge submit metadata into final response for usage parsing - final_data = { + return { **poll_data, "_submit_metadata": submit_data, } - return httpx.Response( - status_code=200, - json=final_data, - request=httpx.Request("GET", polling_url), - ) elif status in ("Error", "Failed"): - return httpx.Response( - status_code=400, - json=poll_data, - request=httpx.Request("GET", polling_url), - ) + error_msg = poll_data.get("error", "Unknown error") + msg = f"{self.provider} image generation failed: {error_msg}" + raise ValueError(msg) await asyncio.sleep(config.POLLING_INTERVAL) + def _make_stream_request( + self, + request_body: dict[str, Any], + *, + endpoint: str | None = None, + **parameters: Any, + ) -> AsyncIterator[dict[str, Any]]: + """BFL Images API does not support SSE streaming in this client.""" + raise StreamingNotSupportedError(model_id=self.model.id) + def _parse_finish_reason(self, response_data: dict[str, Any]) -> FinishReason: """BFL provides status but not structured finish reasons.""" return FinishReason(reason=None) @staticmethod - def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, float | None]: + def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | float | None]: """Map BFL usage fields to unified names. Shared by client and streaming across all capabilities. @@ -134,7 +137,9 @@ def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, float | None]: UsageField.OUTPUT_MP: float(output_mp) if output_mp is not None else None, } - def _parse_usage(self, response_data: dict[str, Any]) -> dict[str, float | None]: + def _parse_usage( + self, response_data: dict[str, Any] + ) -> dict[str, int | float | None]: """Extract usage data from BFL response.""" submit_metadata = response_data.get("_submit_metadata", {}) return BFLImagesClient.map_usage_fields(submit_metadata) @@ -147,5 +152,13 @@ def _parse_content(self, response_data: dict[str, Any]) -> Any: raise ValueError(msg) return result + def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: + """Build metadata dictionary, filtering out content fields.""" + content_fields = {"result"} + filtered_data = { + k: v for k, v in response_data.items() if k not in content_fields + } + return super()._build_metadata(filtered_data) + __all__ = ["BFLImagesClient"] diff --git a/packages/providers/bfl/src/celeste_bfl/images/config.py b/src/celeste/providers/bfl/images/config.py similarity index 88% rename from packages/providers/bfl/src/celeste_bfl/images/config.py rename to src/celeste/providers/bfl/images/config.py index b9f55cdc..37fe91d5 100644 --- a/packages/providers/bfl/src/celeste_bfl/images/config.py +++ b/src/celeste/providers/bfl/images/config.py @@ -4,7 +4,7 @@ class BFLImagesEndpoint(StrEnum): - """Endpoints for Images API.""" + """Endpoints for BFL Images API.""" CREATE_IMAGE = "/v1/{model_id}" diff --git a/packages/providers/bfl/src/celeste_bfl/images/parameters.py b/src/celeste/providers/bfl/images/parameters.py similarity index 100% rename from packages/providers/bfl/src/celeste_bfl/images/parameters.py rename to src/celeste/providers/bfl/images/parameters.py diff --git a/packages/providers/bfl/src/celeste_bfl/images/utils.py b/src/celeste/providers/bfl/images/utils.py similarity index 57% rename from packages/providers/bfl/src/celeste_bfl/images/utils.py rename to src/celeste/providers/bfl/images/utils.py index f80924e3..ed6dfd5a 100644 --- a/packages/providers/bfl/src/celeste_bfl/images/utils.py +++ b/src/celeste/providers/bfl/images/utils.py @@ -1,6 +1,7 @@ """BFL Images API utilities.""" import base64 +from typing import Any from celeste.artifacts import ImageArtifact @@ -24,4 +25,20 @@ def encode_image(image: ImageArtifact) -> str: raise ValueError(msg) -__all__ = ["encode_image"] +def add_reference_images( + request: dict[str, Any], + images: list[ImageArtifact], + *, + start_index: int = 2, +) -> dict[str, Any]: + """Add additional reference images to a BFL request dict. + + BFL expects extra images as sequential fields: ``input_image_2``, + ``input_image_3``, ... + """ + for i, image in enumerate(images, start=start_index): + request[f"input_image_{i}"] = encode_image(image) + return request + + +__all__ = ["add_reference_images", "encode_image"] diff --git a/packages/providers/byteplus/src/celeste_byteplus/py.typed b/src/celeste/providers/bfl/py.typed similarity index 100% rename from packages/providers/byteplus/src/celeste_byteplus/py.typed rename to src/celeste/providers/bfl/py.typed diff --git a/src/celeste/providers/byteplus/__init__.py b/src/celeste/providers/byteplus/__init__.py new file mode 100644 index 00000000..8bc20da8 --- /dev/null +++ b/src/celeste/providers/byteplus/__init__.py @@ -0,0 +1,12 @@ +"""BytePlus provider package for Celeste AI.""" + +from celeste.core import Provider +from celeste.credentials import register_auth + +# Register BytePlus auth config when package is imported +register_auth( # nosec B106 - env var name, not hardcoded password + provider=Provider.BYTEPLUS, + secret_name="BYTEPLUS_API_KEY", + header="Authorization", + prefix="Bearer ", +) diff --git a/packages/providers/byteplus/src/celeste_byteplus/images/__init__.py b/src/celeste/providers/byteplus/images/__init__.py similarity index 100% rename from packages/providers/byteplus/src/celeste_byteplus/images/__init__.py rename to src/celeste/providers/byteplus/images/__init__.py diff --git a/packages/providers/byteplus/src/celeste_byteplus/images/client.py b/src/celeste/providers/byteplus/images/client.py similarity index 74% rename from packages/providers/byteplus/src/celeste_byteplus/images/client.py rename to src/celeste/providers/byteplus/images/client.py index 164e42e0..944ce91a 100644 --- a/packages/providers/byteplus/src/celeste_byteplus/images/client.py +++ b/src/celeste/providers/byteplus/images/client.py @@ -1,10 +1,8 @@ -"""BytePlus Images API client with shared implementation.""" +"""BytePlus Images API client mixin.""" from collections.abc import AsyncIterator from typing import Any -import httpx - from celeste.client import APIMixin from celeste.core import UsageField from celeste.io import FinishReason @@ -31,32 +29,56 @@ def _parse_content(self, response_data, **parameters): # Extract image from images[0] or data[0]... """ + def _build_request( + self, + inputs: Any, + extra_body: dict[str, Any] | None = None, + streaming: bool = False, + **parameters: Any, + ) -> dict[str, Any]: + """Build request with model ID and streaming flag.""" + request_body = super()._build_request( + inputs, extra_body=extra_body, streaming=streaming, **parameters + ) + request_body["model"] = self.model.id + request_body["stream"] = streaming + return request_body + async def _make_request( self, request_body: dict[str, Any], + *, + endpoint: str | None = None, **parameters: Any, - ) -> httpx.Response: + ) -> dict[str, Any]: """Make HTTP request to BytePlus Images API endpoint.""" - request_body["stream"] = False + if endpoint is None: + endpoint = config.BytePlusImagesEndpoint.CREATE_IMAGE headers = { **self.auth.get_headers(), "Content-Type": ApplicationMimeType.JSON, } - return await self.http_client.post( - f"{config.BASE_URL}{config.BytePlusImagesEndpoint.CREATE_IMAGE}", + response = await self.http_client.post( + f"{config.BASE_URL}{endpoint}", headers=headers, json_body=request_body, ) + self._handle_error_response(response) + data: dict[str, Any] = response.json() + return data def _make_stream_request( self, request_body: dict[str, Any], + *, + endpoint: str | None = None, **parameters: Any, ) -> AsyncIterator[dict[str, Any]]: """Make streaming request to BytePlus Images API endpoint.""" - request_body["stream"] = True + if endpoint is None: + endpoint = config.BytePlusImagesEndpoint.CREATE_IMAGE headers = { **self.auth.get_headers(), @@ -64,13 +86,13 @@ def _make_stream_request( } return self.http_client.stream_post( - f"{config.BASE_URL}{config.BytePlusImagesEndpoint.CREATE_IMAGE}", + f"{config.BASE_URL}{endpoint}", headers=headers, json_body=request_body, ) @staticmethod - def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | None]: + def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | float | None]: """Map BytePlus Images usage fields to unified names. Shared by client and streaming across all capabilities. @@ -81,7 +103,9 @@ def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | None]: UsageField.NUM_IMAGES: usage_data.get("generated_images"), } - def _parse_usage(self, response_data: dict[str, Any]) -> dict[str, int | None]: + def _parse_usage( + self, response_data: dict[str, Any] + ) -> dict[str, int | float | None]: """Extract usage data from Images API response. Returns dict that capability clients wrap in their specific Usage type. @@ -102,7 +126,8 @@ def _parse_content(self, response_data: dict[str, Any]) -> Any: if data: return data - return None + msg = "No images or data in response" + raise ValueError(msg) def _parse_finish_reason(self, response_data: dict[str, Any]) -> FinishReason: """BytePlus doesn't provide finish reasons for image generation.""" diff --git a/packages/providers/byteplus/src/celeste_byteplus/images/config.py b/src/celeste/providers/byteplus/images/config.py similarity index 82% rename from packages/providers/byteplus/src/celeste_byteplus/images/config.py rename to src/celeste/providers/byteplus/images/config.py index 62411e9f..719204e3 100644 --- a/packages/providers/byteplus/src/celeste_byteplus/images/config.py +++ b/src/celeste/providers/byteplus/images/config.py @@ -4,7 +4,7 @@ class BytePlusImagesEndpoint(StrEnum): - """Endpoints for Images API.""" + """Endpoints for BytePlus Images API.""" CREATE_IMAGE = "/api/v3/images/generations" diff --git a/packages/providers/byteplus/src/celeste_byteplus/images/parameters.py b/src/celeste/providers/byteplus/images/parameters.py similarity index 100% rename from packages/providers/byteplus/src/celeste_byteplus/images/parameters.py rename to src/celeste/providers/byteplus/images/parameters.py diff --git a/src/celeste/providers/byteplus/images/streaming.py b/src/celeste/providers/byteplus/images/streaming.py new file mode 100644 index 00000000..b9e2e284 --- /dev/null +++ b/src/celeste/providers/byteplus/images/streaming.py @@ -0,0 +1,104 @@ +"""BytePlus Images SSE parsing for streaming.""" + +from typing import Any + +from celeste.io import FinishReason + +from .client import BytePlusImagesClient + + +class BytePlusImagesStream: + """Mixin for BytePlus Images API SSE parsing. + + Provides shared implementation for streaming parsing (provider API level): + - _parse_chunk_content(event_data) - Extract image content from SSE event + - _parse_chunk_usage(event_data) - Extract and normalize usage from SSE event + - _parse_chunk_finish_reason(event_data) - Extract finish reason from SSE event + - _parse_chunk_content_type(event_data) - Get content type ("url" or "b64_json") + - _parse_chunk_error(event_data) - Get error info for failed events + + Handles all image streaming event types: + - image_generation.partial_succeeded - Partial image with url or b64_json + - image_generation.partial_failed - Error event + - image_generation.completed - Final event with usage only + + Modality streams call super() methods which resolve to this via MRO. + """ + + def _parse_chunk_content(self, event_data: dict[str, Any]) -> str | None: + """Extract image content from SSE event. + + Returns url or b64_json string if present, None otherwise. + """ + event_type = event_data.get("type") + + if event_type == "image_generation.partial_succeeded": + url: str | None = event_data.get("url") + if url: + return url + return event_data.get("b64_json") + + return None + + def _parse_chunk_content_type(self, event_data: dict[str, Any]) -> str | None: + """Get content type for the event ("url" or "b64_json").""" + event_type = event_data.get("type") + + if event_type == "image_generation.partial_succeeded": + if event_data.get("url"): + return "url" + if event_data.get("b64_json"): + return "b64_json" + + return None + + def _parse_chunk_usage( + self, event_data: dict[str, Any] + ) -> dict[str, int | float | None] | None: + """Extract and normalize usage from SSE event.""" + event_type = event_data.get("type") + + if event_type == "image_generation.completed": + usage_data = event_data.get("usage") + if usage_data: + return BytePlusImagesClient.map_usage_fields(usage_data) + + return None + + def _parse_chunk_finish_reason( + self, event_data: dict[str, Any] + ) -> FinishReason | None: + """Extract finish reason from SSE event.""" + event_type = event_data.get("type") + + if event_type == "image_generation.completed": + return FinishReason(reason="completed") + + return None + + def _parse_chunk_error(self, event_data: dict[str, Any]) -> dict[str, Any] | None: + """Extract error info from failed events.""" + event_type = event_data.get("type") + + if event_type == "image_generation.partial_failed": + return event_data.get("error") + + return None + + def _is_error_event(self, event_data: dict[str, Any]) -> bool: + """Check if this is an error event.""" + return event_data.get("type") == "image_generation.partial_failed" + + def _build_stream_metadata( + self, raw_events: list[dict[str, Any]] + ) -> dict[str, Any]: + """Filter content-only events for size efficiency (content is in Output.content).""" + filtered = [ + e + for e in raw_events + if e.get("type") != "image_generation.partial_succeeded" + ] + return super()._build_stream_metadata(filtered) # type: ignore[misc] + + +__all__ = ["BytePlusImagesStream"] diff --git a/packages/providers/cohere/src/celeste_cohere/py.typed b/src/celeste/providers/byteplus/py.typed similarity index 100% rename from packages/providers/cohere/src/celeste_cohere/py.typed rename to src/celeste/providers/byteplus/py.typed diff --git a/packages/providers/byteplus/src/celeste_byteplus/videos/__init__.py b/src/celeste/providers/byteplus/videos/__init__.py similarity index 100% rename from packages/providers/byteplus/src/celeste_byteplus/videos/__init__.py rename to src/celeste/providers/byteplus/videos/__init__.py diff --git a/packages/providers/byteplus/src/celeste_byteplus/videos/client.py b/src/celeste/providers/byteplus/videos/client.py similarity index 65% rename from packages/providers/byteplus/src/celeste_byteplus/videos/client.py rename to src/celeste/providers/byteplus/videos/client.py index 3cf12b9d..b4ea1a95 100644 --- a/packages/providers/byteplus/src/celeste_byteplus/videos/client.py +++ b/src/celeste/providers/byteplus/videos/client.py @@ -1,14 +1,14 @@ -"""BytePlus Videos API client with shared implementation.""" +"""BytePlus Videos API client mixin.""" import asyncio import logging import time +from collections.abc import AsyncIterator from typing import Any -import httpx - from celeste.client import APIMixin from celeste.core import UsageField +from celeste.exceptions import StreamingNotSupportedError from celeste.io import FinishReason from celeste.mime_types import ApplicationMimeType @@ -35,11 +35,29 @@ def _parse_content(self, response_data, **parameters): # Extract video from content["video_url"]... """ + def _build_request( + self, + inputs: Any, + extra_body: dict[str, Any] | None = None, + streaming: bool = False, + **parameters: Any, + ) -> dict[str, Any]: + """Build request with model ID and streaming flag.""" + request_body = super()._build_request( + inputs, extra_body=extra_body, streaming=streaming, **parameters + ) + request_body["model"] = self.model.id + if streaming: + request_body["stream"] = True + return request_body + async def _make_request( self, request_body: dict[str, Any], + *, + endpoint: str | None = None, **parameters: Any, - ) -> httpx.Response: + ) -> dict[str, Any]: """Make HTTP request with async polling for BytePlus video generation. Handles the complete async polling workflow: @@ -53,17 +71,18 @@ async def _make_request( "Content-Type": ApplicationMimeType.JSON, } + if endpoint is None: + endpoint = config.BytePlusVideosEndpoint.CREATE_VIDEO + # Phase 1: Submit job logger.debug("Submitting video generation task to BytePlus") submit_response = await self.http_client.post( - f"{config.BASE_URL}{config.BytePlusVideosEndpoint.CREATE_VIDEO}", + f"{config.BASE_URL}{endpoint}", headers=headers, json_body=request_body, ) - if submit_response.status_code != 200: - return submit_response - + self._handle_error_response(submit_response) submit_data = submit_response.json() task_id = submit_data["id"] logger.info(f"BytePlus task submitted: {task_id}") @@ -88,20 +107,14 @@ async def _make_request( headers=headers, ) - if status_response.status_code != 200: - return status_response - - status_data = status_response.json() + self._handle_error_response(status_response) + status_data: dict[str, Any] = status_response.json() status = status_data.get("status") logger.debug(f"BytePlus task {task_id} status: {status}") if status == config.STATUS_SUCCEEDED: logger.info(f"BytePlus task {task_id} completed in {elapsed:.0f}s") - return httpx.Response( - status_code=200, - json=status_data, - request=httpx.Request("GET", status_url), - ) + return status_data if status in (config.STATUS_FAILED, config.STATUS_CANCELED): error = status_data.get("error", {}) @@ -115,8 +128,18 @@ async def _make_request( await asyncio.sleep(config.POLLING_INTERVAL) + def _make_stream_request( + self, + request_body: dict[str, Any], + *, + endpoint: str | None = None, + **parameters: Any, + ) -> AsyncIterator[dict[str, Any]]: + """BytePlus Videos API does not support SSE streaming in this client.""" + raise StreamingNotSupportedError(model_id=self.model.id) + @staticmethod - def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, Any]: + def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | float | None]: """Map BytePlus Videos usage fields to unified names. Shared by client and streaming across all capabilities. @@ -125,14 +148,32 @@ def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, Any]: UsageField.TOTAL_TOKENS: usage_data.get("total_tokens"), } - def _parse_usage(self, response_data: dict[str, Any]) -> dict[str, Any]: + def _parse_usage( + self, response_data: dict[str, Any] + ) -> dict[str, int | float | None]: """Extract usage data from BytePlus API response.""" usage_data = response_data.get("usage", {}) return BytePlusVideosClient.map_usage_fields(usage_data) + def _parse_content(self, response_data: dict[str, Any]) -> Any: + """Parse content from BytePlus video generation response.""" + content = response_data.get("content", {}) + if not content: + msg = "No content in response" + raise ValueError(msg) + return content + def _parse_finish_reason(self, response_data: dict[str, Any]) -> FinishReason: """BytePlus provides status but not structured finish reasons.""" return FinishReason(reason=None) + def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: + """Build metadata dictionary, filtering out content fields.""" + content_fields = {"content"} + filtered_data = { + k: v for k, v in response_data.items() if k not in content_fields + } + return super()._build_metadata(filtered_data) + __all__ = ["BytePlusVideosClient"] diff --git a/packages/providers/byteplus/src/celeste_byteplus/videos/config.py b/src/celeste/providers/byteplus/videos/config.py similarity index 91% rename from packages/providers/byteplus/src/celeste_byteplus/videos/config.py rename to src/celeste/providers/byteplus/videos/config.py index 607acd05..ffcf686f 100644 --- a/packages/providers/byteplus/src/celeste_byteplus/videos/config.py +++ b/src/celeste/providers/byteplus/videos/config.py @@ -4,7 +4,7 @@ class BytePlusVideosEndpoint(StrEnum): - """Endpoints for Videos API.""" + """Endpoints for BytePlus Videos API.""" CREATE_VIDEO = "/api/v3/contents/generations/tasks" GET_VIDEO_STATUS = "/api/v3/contents/generations/tasks/{task_id}" diff --git a/packages/providers/byteplus/src/celeste_byteplus/videos/parameters.py b/src/celeste/providers/byteplus/videos/parameters.py similarity index 100% rename from packages/providers/byteplus/src/celeste_byteplus/videos/parameters.py rename to src/celeste/providers/byteplus/videos/parameters.py diff --git a/src/celeste/providers/cohere/__init__.py b/src/celeste/providers/cohere/__init__.py new file mode 100644 index 00000000..1da5274c --- /dev/null +++ b/src/celeste/providers/cohere/__init__.py @@ -0,0 +1,12 @@ +"""Cohere provider package for Celeste AI.""" + +from celeste.core import Provider +from celeste.credentials import register_auth + +# Register Cohere auth config when package is imported +register_auth( # nosec B106 - env var name, not hardcoded password + provider=Provider.COHERE, + secret_name="COHERE_API_KEY", + header="Authorization", + prefix="bearer ", +) diff --git a/packages/providers/cohere/src/celeste_cohere/chat/__init__.py b/src/celeste/providers/cohere/chat/__init__.py similarity index 100% rename from packages/providers/cohere/src/celeste_cohere/chat/__init__.py rename to src/celeste/providers/cohere/chat/__init__.py diff --git a/packages/providers/cohere/src/celeste_cohere/chat/client.py b/src/celeste/providers/cohere/chat/client.py similarity index 76% rename from packages/providers/cohere/src/celeste_cohere/chat/client.py rename to src/celeste/providers/cohere/chat/client.py index e3e4a3da..cba02b17 100644 --- a/packages/providers/cohere/src/celeste_cohere/chat/client.py +++ b/src/celeste/providers/cohere/chat/client.py @@ -1,10 +1,8 @@ -"""Cohere Chat API client with shared implementation.""" +"""Cohere Chat API client mixin.""" from collections.abc import AsyncIterator from typing import Any -import httpx - from celeste.client import APIMixin from celeste.core import UsageField from celeste.io import FinishReason @@ -32,33 +30,57 @@ def _parse_content(self, response_data, **parameters): return self._transform_output(text, **parameters) """ + def _build_request( + self, + inputs: Any, + extra_body: dict[str, Any] | None = None, + streaming: bool = False, + **parameters: Any, + ) -> dict[str, Any]: + """Build request with model ID and streaming flag.""" + request_body = super()._build_request( + inputs, extra_body=extra_body, streaming=streaming, **parameters + ) + request_body["model"] = self.model.id + if streaming: + request_body["stream"] = True + return request_body + async def _make_request( self, request_body: dict[str, Any], + *, + endpoint: str | None = None, **parameters: Any, - ) -> httpx.Response: + ) -> dict[str, Any]: """Make HTTP request to Cohere Chat API endpoint.""" - request_body["model"] = self.model.id + if endpoint is None: + endpoint = config.CohereChatEndpoint.CREATE_CHAT headers = { **self.auth.get_headers(), "Content-Type": ApplicationMimeType.JSON, } - return await self.http_client.post( - f"{config.BASE_URL}{config.CohereChatEndpoint.CREATE_CHAT}", + response = await self.http_client.post( + f"{config.BASE_URL}{endpoint}", headers=headers, json_body=request_body, ) + self._handle_error_response(response) + data: dict[str, Any] = response.json() + return data def _make_stream_request( self, request_body: dict[str, Any], + *, + endpoint: str | None = None, **parameters: Any, ) -> AsyncIterator[dict[str, Any]]: """Make streaming request to Cohere Chat API endpoint.""" - request_body["model"] = self.model.id - request_body["stream"] = True + if endpoint is None: + endpoint = config.CohereChatEndpoint.CREATE_CHAT headers = { **self.auth.get_headers(), @@ -66,13 +88,13 @@ def _make_stream_request( } return self.http_client.stream_post( - f"{config.BASE_URL}{config.CohereChatEndpoint.CREATE_CHAT}", + f"{config.BASE_URL}{endpoint}", headers=headers, json_body=request_body, ) @staticmethod - def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | None]: + def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | float | None]: """Map Cohere usage fields to unified names. Shared by client and streaming across all capabilities. @@ -86,7 +108,9 @@ def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | None]: UsageField.CACHED_TOKENS: usage_data.get("cached_tokens"), } - def _parse_usage(self, response_data: dict[str, Any]) -> dict[str, int | None]: + def _parse_usage( + self, response_data: dict[str, Any] + ) -> dict[str, int | float | None]: """Extract usage data from Chat API response.""" usage_data = response_data.get("usage", {}) return CohereChatClient.map_usage_fields(usage_data) @@ -109,7 +133,7 @@ def _parse_finish_reason(self, response_data: dict[str, Any]) -> FinishReason: return FinishReason(reason=reason) def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: - """Build metadata dictionary, filtering out content field.""" + """Build metadata dictionary, filtering out content fields.""" content_fields = {"message"} filtered_data = { k: v for k, v in response_data.items() if k not in content_fields diff --git a/packages/providers/cohere/src/celeste_cohere/chat/config.py b/src/celeste/providers/cohere/chat/config.py similarity index 85% rename from packages/providers/cohere/src/celeste_cohere/chat/config.py rename to src/celeste/providers/cohere/chat/config.py index fc6e2c92..bc0a3720 100644 --- a/packages/providers/cohere/src/celeste_cohere/chat/config.py +++ b/src/celeste/providers/cohere/chat/config.py @@ -4,7 +4,7 @@ class CohereChatEndpoint(StrEnum): - """Endpoints for Chat API.""" + """Endpoints for Cohere Chat API.""" CREATE_CHAT = "/v2/chat" LIST_MODELS = "/v2/models" diff --git a/packages/providers/cohere/src/celeste_cohere/chat/parameters.py b/src/celeste/providers/cohere/chat/parameters.py similarity index 93% rename from packages/providers/cohere/src/celeste_cohere/chat/parameters.py rename to src/celeste/providers/cohere/chat/parameters.py index 4ec28588..a75dd560 100644 --- a/packages/providers/cohere/src/celeste_cohere/chat/parameters.py +++ b/src/celeste/providers/cohere/chat/parameters.py @@ -8,7 +8,7 @@ from celeste.models import Model from celeste.parameters import ParameterMapper from celeste.structured_outputs import RefResolvingJsonSchemaGenerator -from celeste.types import StructuredOutput +from celeste.types import TextContent class TemperatureMapper(ParameterMapper): @@ -70,8 +70,8 @@ def map( return request -class OutputSchemaMapper(ParameterMapper): - """Map output_schema to Cohere structured outputs format. +class ResponseFormatMapper(ParameterMapper): + """Map output_schema to Cohere response_format field. Handles both single BaseModel and list[BaseModel] types. Cohere requires top-level object, so lists are wrapped in {items: []}. @@ -113,9 +113,7 @@ def map( } return request - def parse_output( - self, content: StructuredOutput, value: object | None - ) -> StructuredOutput: + def parse_output(self, content: TextContent, value: object | None) -> TextContent: """Parse JSON to BaseModel using Pydantic's TypeAdapter.""" if value is None: return content @@ -141,7 +139,7 @@ def parse_output( __all__ = [ "MaxTokensMapper", - "OutputSchemaMapper", + "ResponseFormatMapper", "TemperatureMapper", "ThinkingMapper", ] diff --git a/src/celeste/providers/cohere/chat/streaming.py b/src/celeste/providers/cohere/chat/streaming.py new file mode 100644 index 00000000..b828eed1 --- /dev/null +++ b/src/celeste/providers/cohere/chat/streaming.py @@ -0,0 +1,93 @@ +"""Cohere Chat SSE parsing for streaming.""" + +from typing import Any + +from celeste.io import FinishReason + +from .client import CohereChatClient + + +class CohereChatStream: + """Mixin for Chat API SSE parsing. + + Provides shared implementation for streaming parsing (provider API level): + - _parse_chunk_content(event_data) - Extract content from SSE event + - _parse_chunk_usage(event_data) - Extract and normalize usage from SSE event + - _parse_chunk_finish_reason(event_data) - Extract finish reason from SSE event + + Modality streams call super() methods which resolve to this via MRO. + """ + + def _parse_chunk_content(self, event_data: dict[str, Any]) -> str | None: + """Extract content from SSE event.""" + event_type = event_data.get("type") + + if event_type == "content-delta": + delta = event_data.get("delta", {}) + message = delta.get("message", {}) + content = message.get("content", {}) + return content.get("text") + + return None + + def _parse_chunk_usage( + self, event_data: dict[str, Any] + ) -> dict[str, int | float | None] | None: + """Extract and normalize usage from SSE event.""" + event_type = event_data.get("type") + + if event_type == "message-end": + delta = event_data.get("delta", {}) + usage_dict = delta.get("usage", {}) + if isinstance(usage_dict, dict): + mapped = CohereChatClient.map_usage_fields(usage_dict) + if ( + mapped.get("input_tokens") is not None + or mapped.get("output_tokens") is not None + ): + return mapped + + if event_type == "stream-end": + usage_data = event_data.get("usage", {}) + if isinstance(usage_data, dict): + mapped = CohereChatClient.map_usage_fields(usage_data) + if ( + mapped.get("input_tokens") is not None + or mapped.get("output_tokens") is not None + ): + return mapped + + return None + + def _parse_chunk_finish_reason( + self, event_data: dict[str, Any] + ) -> FinishReason | None: + """Extract finish reason from SSE event.""" + event_type = event_data.get("type") + + if event_type == "message-end": + delta = event_data.get("delta", {}) + finish_reason = delta.get("finish_reason") + if finish_reason: + return FinishReason(reason=finish_reason) + + if event_type == "stream-end": + finish_reason = event_data.get("finish_reason") + if finish_reason: + return FinishReason(reason=finish_reason) + + return None + + def _build_stream_metadata( + self, raw_events: list[dict[str, Any]] + ) -> dict[str, Any]: + """Filter content-only events for size efficiency (content is in Output.content).""" + filtered_events = [] + for event in raw_events: + event_type = event.get("type", "") + if event_type in {"message-end", "stream-end"}: + filtered_events.append(event) + return super()._build_stream_metadata(filtered_events) # type: ignore[misc] + + +__all__ = ["CohereChatStream"] diff --git a/packages/providers/elevenlabs/src/celeste_elevenlabs/py.typed b/src/celeste/providers/cohere/py.typed similarity index 100% rename from packages/providers/elevenlabs/src/celeste_elevenlabs/py.typed rename to src/celeste/providers/cohere/py.typed diff --git a/src/celeste/providers/deepseek/__init__.py b/src/celeste/providers/deepseek/__init__.py new file mode 100644 index 00000000..c50a2a7f --- /dev/null +++ b/src/celeste/providers/deepseek/__init__.py @@ -0,0 +1,12 @@ +"""DeepSeek provider package for Celeste AI.""" + +from celeste.core import Provider +from celeste.credentials import register_auth + +# Register DeepSeek auth config when package is imported +register_auth( # nosec B106 - env var name, not hardcoded password + provider=Provider.DEEPSEEK, + secret_name="DEEPSEEK_API_KEY", + header="Authorization", + prefix="Bearer ", +) diff --git a/src/celeste/providers/deepseek/chat/__init__.py b/src/celeste/providers/deepseek/chat/__init__.py new file mode 100644 index 00000000..0b50564e --- /dev/null +++ b/src/celeste/providers/deepseek/chat/__init__.py @@ -0,0 +1 @@ +"""DeepSeek Chat API provider package.""" diff --git a/src/celeste/providers/deepseek/chat/client.py b/src/celeste/providers/deepseek/chat/client.py new file mode 100644 index 00000000..f8547520 --- /dev/null +++ b/src/celeste/providers/deepseek/chat/client.py @@ -0,0 +1,151 @@ +"""DeepSeek Chat API client mixin.""" + +from collections.abc import AsyncIterator +from typing import Any + +from celeste.client import APIMixin +from celeste.core import UsageField +from celeste.io import FinishReason +from celeste.mime_types import ApplicationMimeType + +from . import config + + +class DeepSeekChatClient(APIMixin): + """Mixin for DeepSeek Chat API capabilities. + + Provides shared implementation for all capabilities using the Chat API: + - _make_request() - HTTP POST to /v1/chat/completions + - _make_stream_request() - HTTP streaming to /v1/chat/completions + - _parse_usage() - Extract usage dict from response + - _parse_content() - Extract choices array from response + - _parse_finish_reason() - Extract finish reason from response + - _build_metadata() - Filter content fields + + Usage: + class DeepSeekTextGenerationClient(DeepSeekChatClient, TextGenerationClient): + def _parse_content(self, response_data, **parameters): + choices = super()._parse_content(response_data) + message = choices[0].get("message", {}) + content = message.get("content") or "" + return self._transform_output(content, **parameters) + """ + + def _build_request( + self, + inputs: Any, + extra_body: dict[str, Any] | None = None, + streaming: bool = False, + **parameters: Any, + ) -> dict[str, Any]: + """Build request with model ID and streaming flag.""" + request_body = super()._build_request( + inputs, extra_body=extra_body, streaming=streaming, **parameters + ) + request_body["model"] = self.model.id + if streaming: + request_body["stream"] = True + return request_body + + async def _make_request( + self, + request_body: dict[str, Any], + *, + endpoint: str | None = None, + **parameters: Any, + ) -> dict[str, Any]: + """Make HTTP request to DeepSeek Chat API endpoint.""" + if endpoint is None: + endpoint = config.DeepSeekChatEndpoint.CREATE_CHAT + + headers = { + **self.auth.get_headers(), + "Content-Type": ApplicationMimeType.JSON, + } + + response = await self.http_client.post( + f"{config.BASE_URL}{endpoint}", + headers=headers, + json_body=request_body, + ) + self._handle_error_response(response) + data: dict[str, Any] = response.json() + return data + + def _make_stream_request( + self, + request_body: dict[str, Any], + *, + endpoint: str | None = None, + **parameters: Any, + ) -> AsyncIterator[dict[str, Any]]: + """Make streaming request to DeepSeek Chat API endpoint.""" + if endpoint is None: + endpoint = config.DeepSeekChatEndpoint.CREATE_CHAT + + headers = { + **self.auth.get_headers(), + "Content-Type": ApplicationMimeType.JSON, + } + + return self.http_client.stream_post( + f"{config.BASE_URL}{endpoint}", + headers=headers, + json_body=request_body, + ) + + @staticmethod + def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | float | None]: + """Map DeepSeek usage fields to unified names. + + Shared by client and streaming across all capabilities. + """ + prompt_tokens_details = usage_data.get("prompt_tokens_details", {}) + completion_tokens_details = usage_data.get("completion_tokens_details", {}) + return { + UsageField.INPUT_TOKENS: usage_data.get("prompt_tokens"), + UsageField.OUTPUT_TOKENS: usage_data.get("completion_tokens"), + UsageField.TOTAL_TOKENS: usage_data.get("total_tokens"), + UsageField.CACHED_TOKENS: prompt_tokens_details.get("cached_tokens"), + UsageField.REASONING_TOKENS: completion_tokens_details.get( + "reasoning_tokens" + ), + } + + def _parse_usage( + self, response_data: dict[str, Any] + ) -> dict[str, int | float | None]: + """Extract usage data from Chat API response.""" + usage_data = response_data.get("usage", {}) + return DeepSeekChatClient.map_usage_fields(usage_data) + + def _parse_content(self, response_data: dict[str, Any]) -> Any: + """Parse choices array from Chat API response. + + Returns raw choices array that capability clients extract from. + """ + choices = response_data.get("choices", []) + if not choices: + msg = "No choices in response" + raise ValueError(msg) + return choices + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> FinishReason: + """Extract finish reason from Chat API response.""" + choices = response_data.get("choices", []) + if not choices: + reason = None + else: + reason = choices[0].get("finish_reason") + return FinishReason(reason=reason) + + def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: + """Build metadata dictionary, filtering out content fields.""" + content_fields = {"choices"} + filtered_data = { + k: v for k, v in response_data.items() if k not in content_fields + } + return super()._build_metadata(filtered_data) + + +__all__ = ["DeepSeekChatClient"] diff --git a/src/celeste/providers/deepseek/chat/config.py b/src/celeste/providers/deepseek/chat/config.py new file mode 100644 index 00000000..53c4a127 --- /dev/null +++ b/src/celeste/providers/deepseek/chat/config.py @@ -0,0 +1,13 @@ +"""Configuration for DeepSeek Chat API.""" + +from enum import StrEnum + + +class DeepSeekChatEndpoint(StrEnum): + """Endpoints for DeepSeek Chat API.""" + + CREATE_CHAT = "/v1/chat/completions" + LIST_MODELS = "/models" + + +BASE_URL = "https://api.deepseek.com" diff --git a/src/celeste/providers/deepseek/chat/parameters.py b/src/celeste/providers/deepseek/chat/parameters.py new file mode 100644 index 00000000..d2e12373 --- /dev/null +++ b/src/celeste/providers/deepseek/chat/parameters.py @@ -0,0 +1,97 @@ +"""DeepSeek Chat API parameter mappers.""" + +import json +from typing import Any, get_origin + +from pydantic import BaseModel, TypeAdapter + +from celeste.models import Model +from celeste.parameters import ParameterMapper +from celeste.types import TextContent + + +class TemperatureMapper(ParameterMapper): + """Map temperature to DeepSeek temperature field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform temperature into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["temperature"] = validated_value + return request + + +class MaxTokensMapper(ParameterMapper): + """Map max_tokens to DeepSeek max_tokens field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform max_tokens into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["max_tokens"] = validated_value + return request + + +class ResponseFormatMapper(ParameterMapper): + """Map output_schema to DeepSeek response_format field. + + DeepSeek supports basic JSON mode only (no schema validation server-side). + Schema validation happens client-side via parse_output method. + """ + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform output_schema into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["response_format"] = {"type": "json_object"} + return request + + def parse_output(self, content: TextContent, value: object | None) -> TextContent: + """Parse JSON to BaseModel using Pydantic's TypeAdapter.""" + if value is None: + return content + + # If content is already a BaseModel, return it unchanged + if isinstance(content, BaseModel): + return content + if isinstance(content, list) and content and isinstance(content[0], BaseModel): + return content + + if isinstance(content, str): + parsed = json.loads(content) + else: + parsed = content + + # For list[T], handle various formats DeepSeek might return + origin = get_origin(value) + if origin is list and isinstance(parsed, dict): + if "items" in parsed: + parsed = parsed["items"] + else: + parsed = list(parsed.values()) + + return TypeAdapter(value).validate_python(parsed) + + +__all__ = ["MaxTokensMapper", "ResponseFormatMapper", "TemperatureMapper"] diff --git a/src/celeste/providers/deepseek/chat/streaming.py b/src/celeste/providers/deepseek/chat/streaming.py new file mode 100644 index 00000000..586729b3 --- /dev/null +++ b/src/celeste/providers/deepseek/chat/streaming.py @@ -0,0 +1,81 @@ +"""DeepSeek Chat SSE parsing for streaming.""" + +from typing import Any + +from celeste.io import FinishReason + +from .client import DeepSeekChatClient + + +class DeepSeekChatStream: + """Mixin for Chat API SSE parsing. + + Provides shared implementation for streaming parsing (provider API level): + - _parse_chunk_content(event_data) - Extract content from SSE event + - _parse_chunk_usage(event_data) - Extract and normalize usage from SSE event + - _parse_chunk_finish_reason(event_data) - Extract finish reason from SSE event + + Modality streams call super() methods which resolve to this via MRO. + """ + + def _parse_chunk_content(self, event_data: dict[str, Any]) -> str | None: + """Extract content from SSE event.""" + object_type = event_data.get("object") + if object_type != "chat.completion.chunk": + return None + + choices = event_data.get("choices", []) + if not choices: + return None + + first_choice = choices[0] + if not isinstance(first_choice, dict): + return None + + delta = first_choice.get("delta", {}) + if not isinstance(delta, dict): + return None + + return delta.get("content") + + def _parse_chunk_usage( + self, event_data: dict[str, Any] + ) -> dict[str, int | float | None] | None: + """Extract and normalize usage from SSE event.""" + usage_data = event_data.get("usage") + if isinstance(usage_data, dict): + return DeepSeekChatClient.map_usage_fields(usage_data) + + return None + + def _parse_chunk_finish_reason( + self, event_data: dict[str, Any] + ) -> FinishReason | None: + """Extract finish reason from SSE event.""" + object_type = event_data.get("object") + if object_type != "chat.completion.chunk": + return None + + choices = event_data.get("choices", []) + if not choices: + return None + + first_choice = choices[0] + if not isinstance(first_choice, dict): + return None + + finish_reason = first_choice.get("finish_reason") + if finish_reason: + return FinishReason(reason=finish_reason) + + return None + + def _build_stream_metadata( + self, raw_events: list[dict[str, Any]] + ) -> dict[str, Any]: + """Filter content-only events for size efficiency (content is in Output.content).""" + filtered = [event for event in raw_events if event.get("usage")] + return super()._build_stream_metadata(filtered) # type: ignore[misc] + + +__all__ = ["DeepSeekChatStream"] diff --git a/packages/providers/google/src/celeste_google/py.typed b/src/celeste/providers/deepseek/py.typed similarity index 100% rename from packages/providers/google/src/celeste_google/py.typed rename to src/celeste/providers/deepseek/py.typed diff --git a/src/celeste/providers/elevenlabs/__init__.py b/src/celeste/providers/elevenlabs/__init__.py new file mode 100644 index 00000000..95e0ca0f --- /dev/null +++ b/src/celeste/providers/elevenlabs/__init__.py @@ -0,0 +1,11 @@ +"""ElevenLabs 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.ELEVENLABS, + secret_name="ELEVENLABS_API_KEY", + header="xi-api-key", + prefix="", +) diff --git a/packages/providers/gradium/src/celeste_gradium/py.typed b/src/celeste/providers/elevenlabs/py.typed similarity index 100% rename from packages/providers/gradium/src/celeste_gradium/py.typed rename to src/celeste/providers/elevenlabs/py.typed diff --git a/packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/__init__.py b/src/celeste/providers/elevenlabs/text_to_speech/__init__.py similarity index 100% rename from packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/__init__.py rename to src/celeste/providers/elevenlabs/text_to_speech/__init__.py diff --git a/packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/client.py b/src/celeste/providers/elevenlabs/text_to_speech/client.py similarity index 75% rename from packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/client.py rename to src/celeste/providers/elevenlabs/text_to_speech/client.py index e9d1e62f..e1109bcd 100644 --- a/packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/client.py +++ b/src/celeste/providers/elevenlabs/text_to_speech/client.py @@ -1,4 +1,4 @@ -"""ElevenLabs Text-to-Speech API client with shared implementation.""" +"""ElevenLabs TextToSpeech API client mixin.""" from collections.abc import AsyncIterator from typing import Any @@ -6,6 +6,7 @@ import httpx from celeste.client import APIMixin +from celeste.io import FinishReason from celeste.mime_types import ApplicationMimeType, AudioMimeType from . import config @@ -32,11 +33,13 @@ async def generate(self, *args, **parameters): async def _make_request( self, request_body: dict[str, Any], + *, + endpoint: str | None = None, **parameters: Any, - ) -> httpx.Response: + ) -> dict[str, Any]: """Make HTTP request to ElevenLabs TTS endpoint. - Returns the raw response with binary audio content. + Returns dict with binary audio content. Voice ID is extracted from request_body["_voice_id"] and used in URL path. """ # Extract voice_id from request_body (set by VoiceMapper) @@ -48,24 +51,31 @@ async def _make_request( request_body["model_id"] = self.model.id # Build URL with voice_id in path - endpoint = config.ElevenLabsTextToSpeechEndpoint.CREATE_SPEECH.format( - voice_id=voice_id - ) + if endpoint is None: + endpoint = config.ElevenLabsTextToSpeechEndpoint.CREATE_SPEECH + endpoint = endpoint.format(voice_id=voice_id) headers = { **self.auth.get_headers(), "Content-Type": ApplicationMimeType.JSON, } - return await self.http_client.post( + response = await self.http_client.post( f"{config.BASE_URL}{endpoint}", headers=headers, json_body=request_body, ) + self._handle_error_response(response) + return { + "audio_bytes": response.content, + "headers": dict(response.headers), + } def _make_stream_request( self, request_body: dict[str, Any], + *, + endpoint: str | None = None, **parameters: Any, ) -> AsyncIterator[dict[str, Any]]: """Make HTTP streaming request returning binary audio chunks. @@ -82,9 +92,9 @@ def _make_stream_request( request_body["model_id"] = self.model.id # Build URL with voice_id in path - endpoint = config.ElevenLabsTextToSpeechEndpoint.STREAM_SPEECH.format( - voice_id=voice_id - ) + if endpoint is None: + endpoint = config.ElevenLabsTextToSpeechEndpoint.STREAM_SPEECH + endpoint = endpoint.format(voice_id=voice_id) headers = { **self.auth.get_headers(), @@ -129,7 +139,7 @@ async def _stream_binary_audio( yield {"data": chunk} @staticmethod - def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | None]: + def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | float | None]: """Map ElevenLabs usage fields to unified names. Shared by client and streaming across all capabilities. @@ -137,10 +147,28 @@ def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | None]: """ return {} - def _parse_usage(self, response_data: dict[str, Any]) -> dict[str, int | None]: + def _parse_usage( + self, response_data: dict[str, Any] + ) -> dict[str, int | float | None]: """Extract usage data from ElevenLabs TTS response.""" return ElevenLabsTextToSpeechClient.map_usage_fields(response_data) + def _parse_content(self, response_data: dict[str, Any]) -> Any: + """Parse content from ElevenLabs TTS response. + + The TTS endpoint returns binary audio, so base generate() should not call this. + """ + msg = "ElevenLabs TTS returns binary responses; capability client must override generate()" + raise NotImplementedError(msg) + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> FinishReason: + """ElevenLabs TTS doesn't provide finish reasons.""" + return FinishReason(reason=None) + + def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: + """Build metadata dictionary, filtering out content fields.""" + return super()._build_metadata(response_data) + def _map_output_format_to_mime_type( self, output_format: str | None ) -> AudioMimeType: diff --git a/packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/config.py b/src/celeste/providers/elevenlabs/text_to_speech/config.py similarity index 88% rename from packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/config.py rename to src/celeste/providers/elevenlabs/text_to_speech/config.py index 5b0a989d..92454111 100644 --- a/packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/config.py +++ b/src/celeste/providers/elevenlabs/text_to_speech/config.py @@ -4,7 +4,7 @@ class ElevenLabsTextToSpeechEndpoint(StrEnum): - """Endpoints for Text-to-Speech API.""" + """Endpoints for ElevenLabs Text-to-Speech API.""" CREATE_SPEECH = "/v1/text-to-speech/{voice_id}" STREAM_SPEECH = "/v1/text-to-speech/{voice_id}/stream" diff --git a/packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/parameters.py b/src/celeste/providers/elevenlabs/text_to_speech/parameters.py similarity index 97% rename from packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/parameters.py rename to src/celeste/providers/elevenlabs/text_to_speech/parameters.py index 22d691e2..d478fb7a 100644 --- a/packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/parameters.py +++ b/src/celeste/providers/elevenlabs/text_to_speech/parameters.py @@ -1,4 +1,4 @@ -"""ElevenLabs Text To Speech API parameter mappers.""" +"""ElevenLabs TextToSpeech API parameter mappers.""" from typing import Any diff --git a/src/celeste/providers/elevenlabs/text_to_speech/streaming.py b/src/celeste/providers/elevenlabs/text_to_speech/streaming.py new file mode 100644 index 00000000..f94069a5 --- /dev/null +++ b/src/celeste/providers/elevenlabs/text_to_speech/streaming.py @@ -0,0 +1,57 @@ +"""ElevenLabs TextToSpeech streaming parsing.""" + +from typing import Any + +from celeste.io import FinishReason + + +class ElevenLabsTextToSpeechStream: + """Mixin for TextToSpeech stream parsing. + + Provides shared implementation for streaming parsing (provider API level): + - _parse_chunk_content(event_data) - Extract audio bytes from event + - _parse_chunk_usage(event_data) - Extract and normalize usage from event + - _parse_chunk_finish_reason(event_data) - Extract finish reason from event + + ElevenLabs streams raw binary audio, not JSON SSE events. + The mixin client yields {"data": chunk} for each binary chunk. + + Modality streams call super() methods which resolve to this via MRO. + """ + + def _parse_chunk_content(self, event_data: dict[str, Any]) -> bytes | None: + """Extract audio bytes from event. + + Returns audio bytes if present, None otherwise. + """ + return event_data.get("data") + + def _parse_chunk_usage( + self, event_data: dict[str, Any] + ) -> dict[str, int | float | None] | None: + """Extract and normalize usage from event. + + ElevenLabs TTS doesn't return usage in stream. + """ + return None + + def _parse_chunk_finish_reason( + self, event_data: dict[str, Any] + ) -> FinishReason | None: + """Extract finish reason from event. + + ElevenLabs binary stream doesn't have finish reasons. + """ + return None + + def _build_stream_metadata( + self, raw_events: list[dict[str, Any]] + ) -> dict[str, Any]: + """Filter to keep only metadata events. + + ElevenLabs binary stream has no metadata events. + """ + return super()._build_stream_metadata(raw_events) # type: ignore[misc] + + +__all__ = ["ElevenLabsTextToSpeechStream"] diff --git a/src/celeste/providers/google/__init__.py b/src/celeste/providers/google/__init__.py new file mode 100644 index 00000000..4813af7c --- /dev/null +++ b/src/celeste/providers/google/__init__.py @@ -0,0 +1,19 @@ +"""Google provider package for Celeste AI.""" + +from celeste.auth import register_auth +from celeste.core import Provider +from celeste.credentials import register_auth as register_provider_auth + +from .auth import GoogleADC + +# Register Google with API key auth (for Gemini API) +# Cloud TTS overrides to use ADC in its client +register_provider_auth( + Provider.GOOGLE, + secret_name="GOOGLE_API_KEY", # nosec B106 - env var name, not hardcoded password + header="x-goog-api-key", + prefix="", +) + +# Legacy string-based lookup +register_auth("google_adc", GoogleADC) diff --git a/packages/providers/google/src/celeste_google/auth.py b/src/celeste/providers/google/auth.py similarity index 100% rename from packages/providers/google/src/celeste_google/auth.py rename to src/celeste/providers/google/auth.py diff --git a/packages/providers/google/src/celeste_google/cloud_tts/__init__.py b/src/celeste/providers/google/cloud_tts/__init__.py similarity index 100% rename from packages/providers/google/src/celeste_google/cloud_tts/__init__.py rename to src/celeste/providers/google/cloud_tts/__init__.py diff --git a/packages/providers/google/src/celeste_google/cloud_tts/client.py b/src/celeste/providers/google/cloud_tts/client.py similarity index 57% rename from packages/providers/google/src/celeste_google/cloud_tts/client.py rename to src/celeste/providers/google/cloud_tts/client.py index 23ee5770..86854b50 100644 --- a/packages/providers/google/src/celeste_google/cloud_tts/client.py +++ b/src/celeste/providers/google/cloud_tts/client.py @@ -1,14 +1,14 @@ -"""Google Cloud TTS API client with shared implementation.""" +"""Google Cloud TTS API client mixin.""" +from collections.abc import AsyncIterator from typing import Any -import httpx - -from celeste.auth import get_auth_class from celeste.client import APIMixin +from celeste.exceptions import StreamingNotSupportedError from celeste.io import FinishReason from celeste.mime_types import ApplicationMimeType +from ..auth import GoogleADC from . import config @@ -16,10 +16,12 @@ class GoogleCloudTTSClient(APIMixin): """Mixin for Cloud TTS API capabilities. Provides shared implementation for speech generation using Cloud TTS API: - - model_post_init() - Override auth to use GoogleADC (Cloud TTS requires OAuth) - _make_request() - HTTP POST to text:synthesize endpoint - _parse_content() - Extract base64 audio content (generic) + Auth uses GoogleADC (Application Default Credentials), unlike Gemini API which uses API keys. + This is set in model_post_init to override the default API key auth. + Capability clients extend via super() to wrap results in artifacts: class GoogleSpeechGenerationClient(GoogleCloudTTSClient, SpeechGenerationClient): def _parse_content(self, response_data, **params): @@ -28,28 +30,53 @@ def _parse_content(self, response_data, **params): return AudioArtifact(data=audio_bytes, mime_type=..., ...) """ - def model_post_init(self, __context: object) -> None: - """Override auth to use OAuth/ADC (Cloud TTS requires it).""" - super().model_post_init(__context) # type: ignore[misc] - GoogleADC = get_auth_class("google_adc") + def _make_stream_request( + self, + request_body: dict[str, Any], + *, + endpoint: str | None = None, + **parameters: Any, + ) -> AsyncIterator[dict[str, Any]]: + """Cloud TTS does not support SSE streaming in this client.""" + raise StreamingNotSupportedError(model_id=self.model.id) + + @staticmethod + def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | float | None]: + """Map Cloud TTS usage fields to unified names. + + Cloud TTS API doesn't provide usage metadata. + """ + return {} + + def model_post_init(self, _context: Any) -> None: + """Override auth to use ADC for Cloud TTS (not API key like Gemini).""" + super().model_post_init(_context) # type: ignore[misc] object.__setattr__(self, "auth", GoogleADC()) async def _make_request( self, request_body: dict[str, Any], + *, + endpoint: str | None = None, **parameters: Any, - ) -> httpx.Response: + ) -> dict[str, Any]: """Make HTTP request to Cloud TTS synthesize endpoint.""" + if endpoint is None: + endpoint = config.GoogleCloudTTSEndpoint.CREATE_SPEECH + headers = { **self.auth.get_headers(), "Content-Type": ApplicationMimeType.JSON, } - return await self.http_client.post( - f"{config.BASE_URL}{config.GoogleCloudTTSEndpoint.CREATE_SPEECH}", + response = await self.http_client.post( + f"{config.BASE_URL}{endpoint}", headers=headers, json_body=request_body, ) + self._handle_error_response(response) + data: dict[str, Any] = response.json() + return data def _parse_content(self, response_data: dict[str, Any]) -> Any: """Extract base64 audio content from response. @@ -62,16 +89,18 @@ def _parse_content(self, response_data: dict[str, Any]) -> Any: raise ValueError(msg) return audio_content - def _parse_usage(self, response_data: dict[str, Any]) -> dict[str, int | None]: + def _parse_usage( + self, response_data: dict[str, Any] + ) -> dict[str, int | float | None]: """Cloud TTS API doesn't provide usage metadata.""" - return {} + return GoogleCloudTTSClient.map_usage_fields(response_data) def _parse_finish_reason(self, response_data: dict[str, Any]) -> FinishReason: """Cloud TTS API doesn't provide finish reasons.""" return FinishReason(reason=None) def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: - """Build metadata, filtering out audio content.""" + """Build metadata dictionary, filtering out content fields.""" content_fields = {"audioContent"} filtered_data = { k: v for k, v in response_data.items() if k not in content_fields diff --git a/packages/providers/google/src/celeste_google/cloud_tts/config.py b/src/celeste/providers/google/cloud_tts/config.py similarity index 91% rename from packages/providers/google/src/celeste_google/cloud_tts/config.py rename to src/celeste/providers/google/cloud_tts/config.py index 5ba43d34..18f0ff31 100644 --- a/packages/providers/google/src/celeste_google/cloud_tts/config.py +++ b/src/celeste/providers/google/cloud_tts/config.py @@ -4,7 +4,7 @@ class GoogleCloudTTSEndpoint(StrEnum): - """Endpoints for CloudTTS API.""" + """Endpoints for Google CloudTTS API.""" CREATE_SPEECH = "/v1/text:synthesize" LIST_VOICES = "/v1/voices" diff --git a/packages/providers/google/src/celeste_google/cloud_tts/parameters.py b/src/celeste/providers/google/cloud_tts/parameters.py similarity index 100% rename from packages/providers/google/src/celeste_google/cloud_tts/parameters.py rename to src/celeste/providers/google/cloud_tts/parameters.py diff --git a/src/celeste/providers/google/embeddings/__init__.py b/src/celeste/providers/google/embeddings/__init__.py new file mode 100644 index 00000000..9a2aca5b --- /dev/null +++ b/src/celeste/providers/google/embeddings/__init__.py @@ -0,0 +1 @@ +"""Google Embeddings API provider package.""" diff --git a/src/celeste/providers/google/embeddings/client.py b/src/celeste/providers/google/embeddings/client.py new file mode 100644 index 00000000..db267f4b --- /dev/null +++ b/src/celeste/providers/google/embeddings/client.py @@ -0,0 +1,112 @@ +"""Google Embeddings API client mixin.""" + +from collections.abc import AsyncIterator +from typing import Any + +from celeste.client import APIMixin +from celeste.exceptions import StreamingNotSupportedError +from celeste.io import FinishReason +from celeste.mime_types import ApplicationMimeType + +from . import config + + +class GoogleEmbeddingsClient(APIMixin): + def _make_stream_request( + self, + request_body: dict[str, Any], + *, + endpoint: str | None = None, + **parameters: Any, + ) -> AsyncIterator[dict[str, Any]]: + """Embeddings API does not support SSE streaming in this client.""" + raise StreamingNotSupportedError(model_id=self.model.id) + + @staticmethod + def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | float | None]: + """Map Google Embeddings usage fields to unified names. + + Embeddings API doesn't provide usage metadata. + """ + return {} + + """Mixin for Embeddings API capabilities. + + Provides shared implementation for embeddings using the Embeddings API: + - _make_request() - HTTP POST to embedContent or batchEmbedContents endpoint + - _parse_content() - Extract embedding vectors (generic) + + Capability clients extend via super(): + class GoogleEmbeddingsClient(GoogleEmbeddingsClient, EmbeddingsClient): + def _parse_content(self, response_data, **params): + return super()._parse_content(response_data) # No transformation needed + """ + + async def _make_request( + self, + request_body: dict[str, Any], + *, + endpoint: str | None = None, + **parameters: Any, + ) -> dict[str, Any]: + """Make HTTP request to embeddings endpoint.""" + is_batch = "requests" in request_body + endpoint_template = ( + config.GoogleEmbeddingsEndpoint.BATCH_EMBED_CONTENTS + if is_batch + else config.GoogleEmbeddingsEndpoint.EMBED_CONTENT + ) + if endpoint is None: + endpoint = endpoint_template + endpoint = endpoint.format(model_id=self.model.id) + + headers = { + **self.auth.get_headers(), + "Content-Type": ApplicationMimeType.JSON, + } + + response = await self.http_client.post( + f"{config.BASE_URL}{endpoint}", + headers=headers, + json_body=request_body, + ) + self._handle_error_response(response) + data: dict[str, Any] = response.json() + return data + + def _parse_content(self, response_data: dict[str, Any]) -> list[list[float]]: + """Extract embedding vectors from response. + + Returns list of embedding vectors (already generic - no artifacts needed). + """ + # Single embedding response + if "embedding" in response_data: + return [response_data["embedding"]["values"]] + + # Batch embedding response + if "embeddings" in response_data: + return [emb["values"] for emb in response_data["embeddings"]] + + msg = "Unexpected response format: missing 'embedding' or 'embeddings' field" + raise ValueError(msg) + + def _parse_usage( + self, response_data: dict[str, Any] + ) -> dict[str, int | float | None]: + """Embeddings API doesn't provide usage metadata.""" + return GoogleEmbeddingsClient.map_usage_fields(response_data) + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> FinishReason: + """Embeddings API doesn't provide finish reasons.""" + return FinishReason(reason=None) + + def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: + """Build metadata, filtering out embedding content.""" + content_fields = {"embedding", "embeddings"} + filtered_data = { + k: v for k, v in response_data.items() if k not in content_fields + } + return super()._build_metadata(filtered_data) + + +__all__ = ["GoogleEmbeddingsClient"] diff --git a/src/celeste/providers/google/embeddings/config.py b/src/celeste/providers/google/embeddings/config.py new file mode 100644 index 00000000..33822fa0 --- /dev/null +++ b/src/celeste/providers/google/embeddings/config.py @@ -0,0 +1,13 @@ +"""Configuration for Google Embeddings API.""" + +from enum import StrEnum + + +class GoogleEmbeddingsEndpoint(StrEnum): + """Endpoints for Google Embeddings API.""" + + EMBED_CONTENT = "/v1beta/models/{model_id}:embedContent" + BATCH_EMBED_CONTENTS = "/v1beta/models/{model_id}:batchEmbedContents" + + +BASE_URL = "https://generativelanguage.googleapis.com" diff --git a/src/celeste/providers/google/embeddings/parameters.py b/src/celeste/providers/google/embeddings/parameters.py new file mode 100644 index 00000000..fad69e00 --- /dev/null +++ b/src/celeste/providers/google/embeddings/parameters.py @@ -0,0 +1,34 @@ +"""Google Embeddings API parameter mappers.""" + +from typing import Any + +from celeste.models import Model +from celeste.parameters import ParameterMapper + + +class OutputDimensionalityMapper(ParameterMapper): + """Map dimensions to Google Embeddings outputDimensionality field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform dimensions into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + # Batch request: add to each individual request + if "requests" in request: + for req in request["requests"]: + req["outputDimensionality"] = validated_value + else: + # Single request: add at root level + request["outputDimensionality"] = validated_value + + return request + + +__all__ = ["OutputDimensionalityMapper"] diff --git a/packages/providers/google/src/celeste_google/generate_content/__init__.py b/src/celeste/providers/google/generate_content/__init__.py similarity index 100% rename from packages/providers/google/src/celeste_google/generate_content/__init__.py rename to src/celeste/providers/google/generate_content/__init__.py diff --git a/packages/providers/google/src/celeste_google/generate_content/client.py b/src/celeste/providers/google/generate_content/client.py similarity index 84% rename from packages/providers/google/src/celeste_google/generate_content/client.py rename to src/celeste/providers/google/generate_content/client.py index 0ac3426f..ef270342 100644 --- a/packages/providers/google/src/celeste_google/generate_content/client.py +++ b/src/celeste/providers/google/generate_content/client.py @@ -1,10 +1,8 @@ -"""Google GenerateContent API client with shared implementation.""" +"""Google GenerateContent API client mixin.""" from collections.abc import AsyncIterator from typing import Any -import httpx - from celeste.client import APIMixin from celeste.core import UsageField from celeste.io import FinishReason @@ -37,31 +35,40 @@ def _parse_content(self, response_data, **parameters): async def _make_request( self, request_body: dict[str, Any], + *, + endpoint: str | None = None, **parameters: Any, - ) -> httpx.Response: + ) -> dict[str, Any]: """Make HTTP request to generateContent endpoint.""" - endpoint = config.GoogleGenerateContentEndpoint.GENERATE_CONTENT.format( - model_id=self.model.id - ) + if endpoint is None: + endpoint = config.GoogleGenerateContentEndpoint.GENERATE_CONTENT + endpoint = endpoint.format(model_id=self.model.id) + headers = { **self.auth.get_headers(), "Content-Type": ApplicationMimeType.JSON, } - return await self.http_client.post( + response = await self.http_client.post( f"{config.BASE_URL}{endpoint}", headers=headers, json_body=request_body, ) + self._handle_error_response(response) + data: dict[str, Any] = response.json() + return data def _make_stream_request( self, request_body: dict[str, Any], + *, + endpoint: str | None = None, **parameters: Any, ) -> AsyncIterator[dict[str, Any]]: """Make streaming request to streamGenerateContent endpoint.""" - endpoint = config.GoogleGenerateContentEndpoint.STREAM_GENERATE_CONTENT.format( - model_id=self.model.id - ) + if endpoint is None: + endpoint = config.GoogleGenerateContentEndpoint.STREAM_GENERATE_CONTENT + endpoint = endpoint.format(model_id=self.model.id) + headers = { **self.auth.get_headers(), "Content-Type": ApplicationMimeType.JSON, @@ -73,7 +80,7 @@ def _make_stream_request( ) @staticmethod - def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | None]: + def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | float | None]: """Map Google Gemini usage fields to unified names. Shared by client and streaming across all capabilities. @@ -85,7 +92,9 @@ def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | None]: UsageField.REASONING_TOKENS: usage_data.get("thoughtsTokenCount"), } - def _parse_usage(self, response_data: dict[str, Any]) -> dict[str, int | None]: + def _parse_usage( + self, response_data: dict[str, Any] + ) -> dict[str, int | float | None]: """Extract usage data from Gemini usageMetadata.""" usage_metadata = response_data.get("usageMetadata", {}) return GoogleGenerateContentClient.map_usage_fields(usage_metadata) diff --git a/packages/providers/google/src/celeste_google/generate_content/config.py b/src/celeste/providers/google/generate_content/config.py similarity index 94% rename from packages/providers/google/src/celeste_google/generate_content/config.py rename to src/celeste/providers/google/generate_content/config.py index 1075ea7a..9787afc2 100644 --- a/packages/providers/google/src/celeste_google/generate_content/config.py +++ b/src/celeste/providers/google/generate_content/config.py @@ -4,7 +4,7 @@ class GoogleGenerateContentEndpoint(StrEnum): - """Endpoints for GenerateContent API.""" + """Endpoints for Google GenerateContent API.""" GENERATE_CONTENT = "/v1beta/models/{model_id}:generateContent" STREAM_GENERATE_CONTENT = "/v1beta/models/{model_id}:streamGenerateContent?alt=sse" diff --git a/packages/providers/google/src/celeste_google/generate_content/parameters.py b/src/celeste/providers/google/generate_content/parameters.py similarity index 89% rename from packages/providers/google/src/celeste_google/generate_content/parameters.py rename to src/celeste/providers/google/generate_content/parameters.py index 831cf883..939b1c54 100644 --- a/packages/providers/google/src/celeste_google/generate_content/parameters.py +++ b/src/celeste/providers/google/generate_content/parameters.py @@ -1,4 +1,4 @@ -"""Google Gemini API parameter mappers.""" +"""Google GenerateContent API parameter mappers.""" import base64 import json @@ -7,10 +7,10 @@ from pydantic import BaseModel, TypeAdapter from celeste.artifacts import ImageArtifact -from celeste.mime_types import ApplicationMimeType, ImageMimeType +from celeste.mime_types import ApplicationMimeType from celeste.models import Model from celeste.parameters import ParameterMapper -from celeste.types import StructuredOutput +from celeste.types import TextContent class TemperatureMapper(ParameterMapper): @@ -133,34 +133,25 @@ class MediaContentMapper(ParameterMapper): """Map reference_images to Google contents.parts field.""" def _build_image_part(self, image: ImageArtifact) -> dict[str, Any]: - """Build inline_data part from image artifact.""" + """Build a Gemini part from an ImageArtifact.""" if image.url: return {"file_data": {"file_uri": image.url}} - elif image.data: - base64_data = ( + + if image.data: + b64 = ( base64.b64encode(image.data).decode("utf-8") if isinstance(image.data, bytes) else image.data ) - return { - "inline_data": { - "mime_type": image.mime_type or ImageMimeType.JPEG, - "data": base64_data, - } - } - elif image.path: + return {"inline_data": {"mime_type": str(image.mime_type), "data": b64}} + + if image.path: with open(image.path, "rb") as f: - image_bytes = f.read() - base64_data = base64.b64encode(image_bytes).decode("utf-8") - return { - "inline_data": { - "mime_type": image.mime_type or ImageMimeType.JPEG, - "data": base64_data, - } - } - else: - msg = "ImageArtifact must have url, data, or path" - raise ValueError(msg) + b64 = base64.b64encode(f.read()).decode("utf-8") + return {"inline_data": {"mime_type": str(image.mime_type), "data": b64}} + + msg = "ImageArtifact must have url, data, or path" + raise ValueError(msg) def map( self, @@ -208,7 +199,7 @@ def map( return request -class OutputSchemaMapper(ParameterMapper): +class ResponseJsonSchemaMapper(ParameterMapper): """Map output_schema to Google generationConfig.responseJsonSchema field.""" def map( @@ -230,9 +221,7 @@ def map( return request - def parse_output( - self, content: StructuredOutput, value: object | None - ) -> StructuredOutput: + def parse_output(self, content: TextContent, value: object | None) -> TextContent: """Parse JSON to BaseModel using Pydantic's TypeAdapter.""" if value is None: return content @@ -302,7 +291,7 @@ def _remove_unsupported_fields(self, schema: dict[str, Any]) -> dict[str, Any]: "ImageSizeMapper", "MaxOutputTokensMapper", "MediaContentMapper", - "OutputSchemaMapper", + "ResponseJsonSchemaMapper", "TemperatureMapper", "ThinkingBudgetMapper", "ThinkingLevelMapper", diff --git a/src/celeste/providers/google/generate_content/streaming.py b/src/celeste/providers/google/generate_content/streaming.py new file mode 100644 index 00000000..6e7cd096 --- /dev/null +++ b/src/celeste/providers/google/generate_content/streaming.py @@ -0,0 +1,69 @@ +"""Google GenerateContent SSE parsing for streaming.""" + +from typing import Any + +from celeste.io import FinishReason + +from .client import GoogleGenerateContentClient + + +class GoogleGenerateContentStream: + """Mixin for GenerateContent SSE parsing. + + Provides shared implementation for streaming parsing (provider API level): + - _parse_chunk_content(event_data) - Extract content from SSE event + - _parse_chunk_usage(event_data) - Extract and normalize usage from SSE event + - _parse_chunk_finish_reason(event_data) - Extract finish reason from SSE event + + Modality streams call super() methods which resolve to this via MRO. + """ + + def _parse_chunk_content(self, event_data: dict[str, Any]) -> str | None: + """Extract content from SSE event.""" + candidates = event_data.get("candidates", []) + if not candidates: + return None + + candidate = candidates[0] + content = candidate.get("content", {}) + parts = content.get("parts", []) + + if parts: + return parts[0].get("text") + + return None + + def _parse_chunk_usage( + self, event_data: dict[str, Any] + ) -> dict[str, int | float | None] | None: + """Extract and normalize usage from SSE event.""" + usage_data = event_data.get("usageMetadata") + if usage_data: + return GoogleGenerateContentClient.map_usage_fields(usage_data) + + return None + + def _parse_chunk_finish_reason( + self, event_data: dict[str, Any] + ) -> FinishReason | None: + """Extract finish reason from SSE event.""" + candidates = event_data.get("candidates", []) + if not candidates: + return None + + candidate = candidates[0] + finish_reason = candidate.get("finishReason") + if finish_reason: + return FinishReason(reason=finish_reason) + + return None + + def _build_stream_metadata( + self, raw_events: list[dict[str, Any]] + ) -> dict[str, Any]: + """Filter content-only events for size efficiency (content is in Output.content).""" + filtered = [e for e in raw_events if e.get("usageMetadata")] + return super()._build_stream_metadata(filtered) # type: ignore[misc] + + +__all__ = ["GoogleGenerateContentStream"] diff --git a/packages/providers/google/src/celeste_google/imagen/__init__.py b/src/celeste/providers/google/imagen/__init__.py similarity index 100% rename from packages/providers/google/src/celeste_google/imagen/__init__.py rename to src/celeste/providers/google/imagen/__init__.py diff --git a/packages/providers/google/src/celeste_google/imagen/client.py b/src/celeste/providers/google/imagen/client.py similarity index 76% rename from packages/providers/google/src/celeste_google/imagen/client.py rename to src/celeste/providers/google/imagen/client.py index ae91eb2d..ba7fb5f8 100644 --- a/packages/providers/google/src/celeste_google/imagen/client.py +++ b/src/celeste/providers/google/imagen/client.py @@ -1,11 +1,11 @@ -"""Google Imagen API client with shared implementation.""" +"""Google Imagen API client mixin.""" +from collections.abc import AsyncIterator from typing import Any -import httpx - from celeste.client import APIMixin from celeste.core import UsageField +from celeste.exceptions import StreamingNotSupportedError from celeste.io import FinishReason from celeste.mime_types import ApplicationMimeType @@ -34,24 +34,40 @@ def _parse_content(self, response_data, **parameters): async def _make_request( self, request_body: dict[str, Any], + *, + endpoint: str | None = None, **parameters: Any, - ) -> httpx.Response: + ) -> dict[str, Any]: """Make HTTP request to Imagen :predict endpoint.""" - endpoint = config.GoogleImagenEndpoint.CREATE_IMAGE.format( - model_id=self.model.id - ) + if endpoint is None: + endpoint = config.GoogleImagenEndpoint.CREATE_IMAGE + endpoint = endpoint.format(model_id=self.model.id) + headers = { **self.auth.get_headers(), "Content-Type": ApplicationMimeType.JSON, } - return await self.http_client.post( + response = await self.http_client.post( f"{config.BASE_URL}{endpoint}", headers=headers, json_body=request_body, ) + self._handle_error_response(response) + data: dict[str, Any] = response.json() + return data + + def _make_stream_request( + self, + request_body: dict[str, Any], + *, + endpoint: str | None = None, + **parameters: Any, + ) -> AsyncIterator[dict[str, Any]]: + """Imagen API does not support SSE streaming in this client.""" + raise StreamingNotSupportedError(model_id=self.model.id) @staticmethod - def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | None]: + def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | float | None]: """Map Google Imagen usage fields to unified names. Shared by client and streaming across all capabilities. @@ -62,7 +78,9 @@ def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | None]: UsageField.NUM_IMAGES: len(predictions), } - def _parse_usage(self, response_data: dict[str, Any]) -> dict[str, int | None]: + def _parse_usage( + self, response_data: dict[str, Any] + ) -> dict[str, int | float | None]: """Extract usage data from Imagen API response.""" predictions = response_data.get("predictions", []) return GoogleImagenClient.map_usage_fields({"predictions": predictions}) diff --git a/packages/providers/google/src/celeste_google/imagen/config.py b/src/celeste/providers/google/imagen/config.py similarity index 83% rename from packages/providers/google/src/celeste_google/imagen/config.py rename to src/celeste/providers/google/imagen/config.py index f447051f..f2d16c48 100644 --- a/packages/providers/google/src/celeste_google/imagen/config.py +++ b/src/celeste/providers/google/imagen/config.py @@ -4,7 +4,7 @@ class GoogleImagenEndpoint(StrEnum): - """Endpoints for Imagen API.""" + """Endpoints for Google Imagen API.""" CREATE_IMAGE = "/v1beta/models/{model_id}:predict" diff --git a/packages/providers/google/src/celeste_google/imagen/parameters.py b/src/celeste/providers/google/imagen/parameters.py similarity index 100% rename from packages/providers/google/src/celeste_google/imagen/parameters.py rename to src/celeste/providers/google/imagen/parameters.py diff --git a/src/celeste/providers/google/interactions/__init__.py b/src/celeste/providers/google/interactions/__init__.py new file mode 100644 index 00000000..f8afdefb --- /dev/null +++ b/src/celeste/providers/google/interactions/__init__.py @@ -0,0 +1 @@ +"""Google Interactions API provider package.""" diff --git a/src/celeste/providers/google/interactions/client.py b/src/celeste/providers/google/interactions/client.py new file mode 100644 index 00000000..4de0b3c4 --- /dev/null +++ b/src/celeste/providers/google/interactions/client.py @@ -0,0 +1,172 @@ +"""Google Interactions API client mixin.""" + +from collections.abc import AsyncIterator +from typing import Any + +import httpx + +from celeste.client import APIMixin +from celeste.core import UsageField +from celeste.io import FinishReason +from celeste.mime_types import ApplicationMimeType + +from . import config + + +class GoogleInteractionsClient(APIMixin): + """Mixin for Interactions API capabilities. + + Provides shared implementation for all capabilities using the Interactions API: + - _make_request() - HTTP POST to /v1beta/interactions + - _make_stream_request() - HTTP streaming to /v1beta/interactions?alt=sse + - _get_interaction() - HTTP GET to retrieve interaction by ID + - _parse_usage() - Extract usage dict from usage metadata + - _parse_content() - Extract outputs array from response + - _parse_finish_reason() - Extract finish reason string from response + - _build_metadata() - Filter content fields + + Capability clients extend parsing methods via super() to wrap/transform results. + + Usage: + class GoogleInteractionsTextGenerationClient( + GoogleInteractionsClient, TextGenerationClient + ): + def _parse_content(self, response_data, **parameters): + outputs = super()._parse_content(response_data) + text = "".join(o.get("text", "") for o in outputs if o.get("type") == "text") + return self._transform_output(text, **parameters) + """ + + def _build_request( + self, + inputs: Any, + extra_body: dict[str, Any] | None = None, + streaming: bool = False, + **parameters: Any, + ) -> dict[str, Any]: + """Build request with model ID and streaming flag.""" + request_body = super()._build_request( + inputs, extra_body=extra_body, streaming=streaming, **parameters + ) + request_body["model"] = self.model.id + if streaming: + request_body["stream"] = True + return request_body + + async def _make_request( + self, + request_body: dict[str, Any], + *, + endpoint: str | None = None, + **parameters: Any, + ) -> dict[str, Any]: + """Make HTTP request to interactions endpoint.""" + if endpoint is None: + endpoint = config.GoogleInteractionsEndpoint.CREATE_INTERACTION + + headers = { + **self.auth.get_headers(), + "Content-Type": ApplicationMimeType.JSON, + } + response = await self.http_client.post( + f"{config.BASE_URL}{endpoint}", + headers=headers, + json_body=request_body, + ) + self._handle_error_response(response) + data: dict[str, Any] = response.json() + return data + + def _make_stream_request( + self, + request_body: dict[str, Any], + *, + endpoint: str | None = None, + **parameters: Any, + ) -> AsyncIterator[dict[str, Any]]: + """Make streaming request to interactions endpoint.""" + if endpoint is None: + endpoint = config.GoogleInteractionsEndpoint.STREAM_INTERACTION + + headers = { + **self.auth.get_headers(), + "Content-Type": ApplicationMimeType.JSON, + } + return self.http_client.stream_post( + f"{config.BASE_URL}{endpoint}", + headers=headers, + json_body=request_body, + ) + + async def _get_interaction( + self, + interaction_id: str, + ) -> httpx.Response: + """Get an existing interaction by ID. + + Used for polling background interactions or retrieving previous context. + """ + endpoint = config.GoogleInteractionsEndpoint.GET_INTERACTION.format( + interaction_id=interaction_id + ) + headers = self.auth.get_headers() + + return await self.http_client.get( + f"{config.BASE_URL}{endpoint}", + headers=headers, + ) + + @staticmethod + def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | float | None]: + """Map Google Interactions usage fields to unified names. + + Shared by client and streaming across all capabilities. + """ + return { + UsageField.INPUT_TOKENS: usage_data.get("prompt_token_count"), + UsageField.OUTPUT_TOKENS: usage_data.get("candidates_token_count"), + UsageField.TOTAL_TOKENS: usage_data.get("total_token_count"), + UsageField.REASONING_TOKENS: usage_data.get("thoughts_token_count"), + } + + def _parse_usage( + self, response_data: dict[str, Any] + ) -> dict[str, int | float | None]: + """Extract usage data from Interactions usage metadata.""" + usage_metadata = response_data.get("usage", {}) + return GoogleInteractionsClient.map_usage_fields(usage_metadata) + + def _parse_content(self, response_data: dict[str, Any]) -> Any: + """Return all outputs from response. + + Returns list of output objects that capability clients extract content from. + """ + outputs = response_data.get("outputs", []) + if not outputs: + msg = "No outputs in response" + raise ValueError(msg) + return outputs + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> FinishReason: + """Extract finish reason from Interactions response. + + Returns FinishReason that capability clients wrap in their specific type. + """ + # Try to find finish_reason at top level or in last output + reason = response_data.get("finish_reason") + if not reason: + outputs = response_data.get("outputs", []) + if outputs: + reason = outputs[-1].get("finish_reason") + return FinishReason(reason=reason) + + def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: + """Build metadata dictionary, filtering out content fields.""" + content_fields = {"outputs"} + filtered_data = { + k: v for k, v in response_data.items() if k not in content_fields + } + return super()._build_metadata(filtered_data) + + +__all__ = ["GoogleInteractionsClient"] diff --git a/src/celeste/providers/google/interactions/config.py b/src/celeste/providers/google/interactions/config.py new file mode 100644 index 00000000..31703ea1 --- /dev/null +++ b/src/celeste/providers/google/interactions/config.py @@ -0,0 +1,25 @@ +"""Configuration for Google Interactions API.""" + +from enum import StrEnum + + +class GoogleInteractionsEndpoint(StrEnum): + """Endpoints for Google Interactions API.""" + + CREATE_INTERACTION = "/v1beta/interactions" + GET_INTERACTION = "/v1beta/interactions/{interaction_id}" + STREAM_INTERACTION = "/v1beta/interactions?alt=sse" + + +BASE_URL = "https://generativelanguage.googleapis.com" + +# Polling Configuration (for background mode) +POLLING_INTERVAL = 5 # seconds +POLLING_TIMEOUT = 300 # 5 minutes + +# Status Constants +STATUS_COMPLETED = "completed" +STATUS_IN_PROGRESS = "in_progress" +STATUS_REQUIRES_ACTION = "requires_action" +STATUS_FAILED = "failed" +STATUS_CANCELLED = "cancelled" diff --git a/src/celeste/providers/google/interactions/parameters.py b/src/celeste/providers/google/interactions/parameters.py new file mode 100644 index 00000000..ba683b18 --- /dev/null +++ b/src/celeste/providers/google/interactions/parameters.py @@ -0,0 +1,380 @@ +"""Google Interactions API parameter mappers.""" + +import base64 +import json +from typing import Any, get_args, get_origin + +from pydantic import BaseModel, TypeAdapter + +from celeste.artifacts import ImageArtifact +from celeste.models import Model +from celeste.parameters import ParameterMapper +from celeste.types import TextContent + + +class TemperatureMapper(ParameterMapper): + """Map temperature to Google Interactions generation_config.temperature field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform temperature into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request.setdefault("generation_config", {})["temperature"] = validated_value + return request + + +class MaxOutputTokensMapper(ParameterMapper): + """Map max_tokens to Google Interactions generation_config.max_output_tokens field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform max_tokens into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request.setdefault("generation_config", {})["max_output_tokens"] = ( + validated_value + ) + return request + + +class ThinkingBudgetMapper(ParameterMapper): + """Map thinking_budget to Interactions generation_config.thinking_config.thinking_budget.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform thinking_budget into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request.setdefault("generation_config", {}).setdefault("thinking_config", {})[ + "thinking_budget" + ] = validated_value + return request + + +class ThinkingLevelMapper(ParameterMapper): + """Map thinking_level to Interactions generation_config.thinking_level field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform thinking_level into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request.setdefault("generation_config", {})["thinking_level"] = validated_value + return request + + +class PreviousInteractionIdMapper(ParameterMapper): + """Map previous_interaction_id for stateful conversations.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Add previous_interaction_id to request for conversation continuity.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["previous_interaction_id"] = validated_value + return request + + +class BackgroundMapper(ParameterMapper): + """Map background mode for long-running operations.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Enable background execution mode.""" + validated_value = self._validate_value(value, model) + if not validated_value: + return request + + request["background"] = True + return request + + +class GoogleSearchMapper(ParameterMapper): + """Map google_search to Google Interactions tools field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Add google_search tool to request.""" + validated_value = self._validate_value(value, model) + if not validated_value: + return request + + tools = request.setdefault("tools", []) + tools.append({"type": "google_search"}) + return request + + +class CodeExecutionMapper(ParameterMapper): + """Map code_execution to Google Interactions tools field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Enable code execution tool.""" + validated_value = self._validate_value(value, model) + if not validated_value: + return request + + tools = request.setdefault("tools", []) + tools.append({"type": "code_execution"}) + return request + + +class UrlContextMapper(ParameterMapper): + """Map url_context to Google Interactions tools field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Enable URL context tool for web page analysis.""" + validated_value = self._validate_value(value, model) + if not validated_value: + return request + + tools = request.setdefault("tools", []) + tools.append({"type": "url_context"}) + return request + + +class ResponseFormatMapper(ParameterMapper): + """Map response_format to Google Interactions response_format field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Add response_format (JSON schema) to request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + schema = self._convert_to_google_schema(validated_value) + request["response_format"] = schema + return request + + def parse_output(self, content: TextContent, value: object | None) -> TextContent: + """Parse JSON to BaseModel using Pydantic's TypeAdapter.""" + if value is None: + return content + + # If content is already a BaseModel, return it unchanged + if isinstance(content, BaseModel): + return content + if isinstance(content, list) and content and isinstance(content[0], BaseModel): + return content + + parsed = json.loads(content) if isinstance(content, str) else content + + # For list[T], handle various formats Google might return + origin = get_origin(value) + if origin is list and isinstance(parsed, dict): + # Google returns arrays directly in JSON schema, but might wrap in object + if "items" in parsed: + parsed = parsed["items"] + else: + # If it's a dict but not wrapped, try to extract array values + parsed = list(parsed.values()) if parsed else [] + + return TypeAdapter(value).validate_python(parsed) + + def _convert_to_google_schema(self, output_schema: Any) -> dict[str, Any]: # noqa: ANN401 + """Convert Pydantic BaseModel or list[BaseModel] to Google JSON Schema format.""" + origin = get_origin(output_schema) + if origin is list: + inner_type = get_args(output_schema)[0] + items_schema = inner_type.model_json_schema() + defs = items_schema.get("$defs", {}) + items_schema_clean = {k: v for k, v in items_schema.items() if k != "$defs"} + json_schema = {"type": "array", "items": items_schema_clean} + if defs: + json_schema["$defs"] = defs + else: + json_schema = output_schema.model_json_schema() + + json_schema = self._remove_unsupported_fields(json_schema) + return json_schema + + def _remove_unsupported_fields(self, schema: dict[str, Any]) -> dict[str, Any]: + """Remove unsupported metadata fields from schema.""" + result: dict[str, Any] = {} + + for key, value in schema.items(): + if key == "title": + continue + + if isinstance(value, dict): + result[key] = self._remove_unsupported_fields(value) + elif isinstance(value, list): + result[key] = [ + self._remove_unsupported_fields(item) + if isinstance(item, dict) + else item + for item in value + ] + else: + result[key] = value + + return result + + +class MediaContentMapper(ParameterMapper): + """Map reference_images to Google Interactions input content.""" + + def _build_image_part(self, image: ImageArtifact) -> dict[str, Any]: + """Build content part from image artifact.""" + if image.url: + return {"type": "image", "uri": image.url} + elif image.data: + base64_data = ( + base64.b64encode(image.data).decode("utf-8") + if isinstance(image.data, bytes) + else image.data + ) + return { + "type": "image", + "data": base64_data, + "mime_type": image.mime_type, + } + elif image.path: + with open(image.path, "rb") as f: + image_bytes = f.read() + base64_data = base64.b64encode(image_bytes).decode("utf-8") + return { + "type": "image", + "data": base64_data, + "mime_type": image.mime_type, + } + else: + msg = "ImageArtifact must have url, data, or path" + raise ValueError(msg) + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform reference_images into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + # Convert list of ImageArtifact to list of image parts + image_parts = [self._build_image_part(img) for img in validated_value] + + # For Interactions API, input can be an array of content parts + current_input = request.get("input") + if isinstance(current_input, str): + # Convert string input to content array with text and images + request["input"] = [ + *image_parts, + {"type": "text", "text": current_input}, + ] + elif isinstance(current_input, list): + # Prepend images to existing content array + request["input"] = [*image_parts, *current_input] + else: + # Just add images + request["input"] = image_parts + + return request + + +class ResponseModalitiesMapper(ParameterMapper): + """Map response_modalities for multimodal output generation.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Set response modalities for output (e.g., IMAGE, TEXT).""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["response_modalities"] = validated_value + return request + + +class SystemInstructionMapper(ParameterMapper): + """Map system_instruction for system prompts.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Add system instruction to request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["system_instruction"] = validated_value + return request + + +__all__ = [ + "BackgroundMapper", + "CodeExecutionMapper", + "GoogleSearchMapper", + "MaxOutputTokensMapper", + "MediaContentMapper", + "PreviousInteractionIdMapper", + "ResponseFormatMapper", + "ResponseModalitiesMapper", + "SystemInstructionMapper", + "TemperatureMapper", + "ThinkingBudgetMapper", + "ThinkingLevelMapper", + "UrlContextMapper", +] diff --git a/src/celeste/providers/google/interactions/streaming.py b/src/celeste/providers/google/interactions/streaming.py new file mode 100644 index 00000000..375665d5 --- /dev/null +++ b/src/celeste/providers/google/interactions/streaming.py @@ -0,0 +1,110 @@ +"""Google Interactions SSE parsing for streaming.""" + +from typing import Any + +from celeste.io import FinishReason + +from .client import GoogleInteractionsClient + + +class GoogleInteractionsStream: + """Mixin for Interactions SSE parsing. + + Provides shared implementation for streaming parsing (provider API level): + - _parse_chunk_content(event_data) - Extract content from SSE event + - _parse_chunk_usage(event_data) - Extract and normalize usage from SSE event + - _parse_chunk_finish_reason(event_data) - Extract finish reason from SSE event + + Interactions API streaming uses different event types than GenerateContent: + - content.delta: Incremental content updates + - interaction.complete: Final interaction data with usage + + Modality streams call super() methods which resolve to this via MRO. + """ + + def _parse_chunk_content(self, event_data: dict[str, Any]) -> str | None: + """Extract content from SSE event. + + Returns content string if present, None otherwise. + """ + event_type = event_data.get("event_type") or event_data.get("type") + + # Handle content delta events + if event_type == "content.delta": + delta = event_data.get("delta", {}) + delta_type = delta.get("type") + + if delta_type == "text": + return delta.get("text", "") + # Note: thought deltas are not returned as content (modality-specific handling) + + # Handle interaction complete events + if event_type == "interaction.complete": + interaction = event_data.get("interaction", {}) + outputs = interaction.get("outputs", []) + + # Extract text from outputs + text_content = "" + for output in outputs: + output_type = output.get("type") + if output_type == "text": + text_content += output.get("text", "") + if text_content: + return text_content + + return None + + def _parse_chunk_usage( + self, event_data: dict[str, Any] + ) -> dict[str, int | float | None] | None: + """Extract and normalize usage from SSE event. + + Returns normalized usage dict if present, None otherwise. + """ + event_type = event_data.get("event_type") or event_data.get("type") + + # Handle interaction complete events + if event_type == "interaction.complete": + interaction = event_data.get("interaction", {}) + usage_data = interaction.get("usage") + if usage_data: + return GoogleInteractionsClient.map_usage_fields(usage_data) + + return None + + def _parse_chunk_finish_reason( + self, event_data: dict[str, Any] + ) -> FinishReason | None: + """Extract finish reason from SSE event. + + Returns FinishReason if present, None otherwise. + """ + event_type = event_data.get("event_type") or event_data.get("type") + + # Handle interaction complete events + if event_type == "interaction.complete": + interaction = event_data.get("interaction", {}) + outputs = interaction.get("outputs", []) + + # Check for finish_reason in various locations + finish_reason = interaction.get("finish_reason") + if not finish_reason and outputs: + finish_reason = outputs[-1].get("finish_reason") + if finish_reason: + return FinishReason(reason=finish_reason) + + return None + + def _build_stream_metadata( + self, raw_events: list[dict[str, Any]] + ) -> dict[str, Any]: + """Filter content-only events for size efficiency (content is in Output.content).""" + filtered = [ + e + for e in raw_events + if (e.get("type", "") or e.get("event_type", "")) != "content.delta" + ] + return super()._build_stream_metadata(filtered) # type: ignore[misc] + + +__all__ = ["GoogleInteractionsStream"] diff --git a/packages/providers/mistral/src/celeste_mistral/py.typed b/src/celeste/providers/google/py.typed similarity index 100% rename from packages/providers/mistral/src/celeste_mistral/py.typed rename to src/celeste/providers/google/py.typed diff --git a/packages/providers/google/src/celeste_google/veo/__init__.py b/src/celeste/providers/google/veo/__init__.py similarity index 100% rename from packages/providers/google/src/celeste_google/veo/__init__.py rename to src/celeste/providers/google/veo/__init__.py diff --git a/packages/providers/google/src/celeste_google/veo/client.py b/src/celeste/providers/google/veo/client.py similarity index 76% rename from packages/providers/google/src/celeste_google/veo/client.py rename to src/celeste/providers/google/veo/client.py index a48d590c..7c29643c 100644 --- a/packages/providers/google/src/celeste_google/veo/client.py +++ b/src/celeste/providers/google/veo/client.py @@ -1,13 +1,13 @@ -"""Google Veo API client with shared implementation.""" +"""Google Veo API client mixin.""" import asyncio -import json import logging +from collections.abc import AsyncIterator from typing import Any -import httpx - from celeste.client import APIMixin +from celeste.exceptions import StreamingNotSupportedError +from celeste.io import FinishReason from celeste.mime_types import ApplicationMimeType from . import config @@ -34,14 +34,27 @@ async def download_content(self, artifact: VideoArtifact) -> VideoArtifact: return VideoArtifact(data=video_bytes, mime_type=VideoMimeType.MP4, ...) """ + def _make_stream_request( + self, + request_body: dict[str, Any], + *, + endpoint: str | None = None, + **parameters: Any, + ) -> AsyncIterator[dict[str, Any]]: + """Veo API does not support SSE streaming in this client.""" + raise StreamingNotSupportedError(model_id=self.model.id) + async def _make_request( self, request_body: dict[str, Any], + *, + endpoint: str | None = None, **parameters: Any, - ) -> httpx.Response: + ) -> dict[str, Any]: """Make HTTP request with async polling for Veo video generation.""" - model_id = self.model.id - endpoint = config.GoogleVeoEndpoint.CREATE_VIDEO.format(model_id=model_id) + if endpoint is None: + endpoint = config.GoogleVeoEndpoint.CREATE_VIDEO + endpoint = endpoint.format(model_id=self.model.id) url = f"{config.BASE_URL}{endpoint}" auth_headers = self.auth.get_headers() @@ -50,7 +63,7 @@ async def _make_request( "Content-Type": ApplicationMimeType.JSON, } - logger.info(f"Initiating video generation with model {model_id}") + logger.info(f"Initiating video generation with model {self.model.id}") response = await self.http_client.post( url, headers=headers, @@ -59,7 +72,7 @@ async def _make_request( ) self._handle_error_response(response) - operation_data = response.json() + operation_data: dict[str, Any] = response.json() operation_name = operation_data["name"] logger.info(f"Video generation started: {operation_name}") @@ -91,11 +104,7 @@ async def _make_request( logger.info(f"Video generation completed: {operation_name}") break - return httpx.Response( - 200, - content=json.dumps(operation_data).encode(), - headers={"Content-Type": ApplicationMimeType.JSON}, - ) + return operation_data def _parse_content(self, response_data: dict[str, Any]) -> Any: """Extract raw video dict from response. @@ -112,7 +121,7 @@ def _parse_content(self, response_data: dict[str, Any]) -> Any: return generated_samples[0].get("video", {}) @staticmethod - def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, Any]: + def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | float | None]: """Map Google Veo usage fields to unified names. Shared by client and streaming across all capabilities. @@ -120,10 +129,24 @@ def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, Any]: """ return {} - def _parse_usage(self, response_data: dict[str, Any]) -> dict[str, Any]: + def _parse_usage( + self, response_data: dict[str, Any] + ) -> dict[str, int | float | None]: """Extract usage data from Veo API response.""" return GoogleVeoClient.map_usage_fields(response_data) + def _parse_finish_reason(self, response_data: dict[str, Any]) -> FinishReason: + """Veo API doesn't provide finish reasons.""" + return FinishReason(reason=None) + + def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: + """Build metadata dictionary, filtering out content fields.""" + content_fields = {"response"} + filtered_data = { + k: v for k, v in response_data.items() if k not in content_fields + } + return super()._build_metadata(filtered_data) + async def download_content(self, url: str) -> bytes: """Download video content from GCS URL. diff --git a/packages/providers/google/src/celeste_google/veo/config.py b/src/celeste/providers/google/veo/config.py similarity index 92% rename from packages/providers/google/src/celeste_google/veo/config.py rename to src/celeste/providers/google/veo/config.py index 41405db0..4c773fea 100644 --- a/packages/providers/google/src/celeste_google/veo/config.py +++ b/src/celeste/providers/google/veo/config.py @@ -4,7 +4,7 @@ class GoogleVeoEndpoint(StrEnum): - """Endpoints for Veo API.""" + """Endpoints for Google Veo API.""" CREATE_VIDEO = "/v1beta/models/{model_id}:predictLongRunning" GET_OPERATION = "/v1beta/{operation_name}" diff --git a/packages/providers/google/src/celeste_google/veo/parameters.py b/src/celeste/providers/google/veo/parameters.py similarity index 67% rename from packages/providers/google/src/celeste_google/veo/parameters.py rename to src/celeste/providers/google/veo/parameters.py index de6f9b3f..8b98110b 100644 --- a/packages/providers/google/src/celeste_google/veo/parameters.py +++ b/src/celeste/providers/google/veo/parameters.py @@ -2,10 +2,10 @@ from typing import Any -from celeste import image_to_data_uri from celeste.exceptions import ValidationError from celeste.models import Model from celeste.parameters import ParameterMapper +from celeste.utils import detect_mime_type class AspectRatioMapper(ParameterMapper): @@ -86,25 +86,18 @@ def map( reference_images = [] # Validated value is list[ImageArtifact] based on capability constraints for img in validated_value: + image_bytes = img.get_bytes() + mime = img.mime_type or detect_mime_type(image_bytes) + mime_str = mime.value if mime else None + ref_image: dict[str, Any] = { - "image": {}, + "image": { + "bytesBase64Encoded": img.get_base64(), + "mimeType": mime_str, + }, "referenceType": "asset", } - # Convert to data URI using core utility - try: - data_uri = image_to_data_uri(img) - - # Extract base64 data from data URI - header, encoded = data_uri.split(",", 1) - mime_type = header.split(":")[1].split(";")[0] - - ref_image["image"]["bytesBase64Encoded"] = encoded - ref_image["image"]["mimeType"] = mime_type - except (ValueError, IndexError, OSError): - msg = "Failed to process reference image. Ensure valid data/path/url." - raise ValidationError(msg) from None - reference_images.append(ref_image) request.setdefault("instances", [{}])[0]["referenceImages"] = reference_images @@ -125,22 +118,15 @@ def map( if validated_value is None: return request - # Convert to data URI using core utility - try: - data_uri = image_to_data_uri(validated_value) + image_bytes = validated_value.get_bytes() + mime = validated_value.mime_type or detect_mime_type(image_bytes) + mime_str = mime.value if mime else None - # Extract base64 data from data URI - header, encoded = data_uri.split(",", 1) - mime_type = header.split(":")[1].split(";")[0] - - # Set image in instances[0].image - request.setdefault("instances", [{}])[0]["image"] = { - "bytesBase64Encoded": encoded, - "mimeType": mime_type, - } - except (ValueError, IndexError, OSError): - msg = "Failed to process first_frame. Ensure valid data/path/url." - raise ValidationError(msg) from None + # Set image in instances[0].image + request.setdefault("instances", [{}])[0]["image"] = { + "bytesBase64Encoded": validated_value.get_base64(), + "mimeType": mime_str, + } return request @@ -165,22 +151,15 @@ def map( msg = "last_frame requires first_frame to be provided" raise ValidationError(msg) - # Convert to data URI using core utility - try: - data_uri = image_to_data_uri(validated_value) + image_bytes = validated_value.get_bytes() + mime = validated_value.mime_type or detect_mime_type(image_bytes) + mime_str = mime.value if mime else None - # Extract base64 data from data URI - header, encoded = data_uri.split(",", 1) - mime_type = header.split(":")[1].split(";")[0] - - # Set lastFrame in instances[0] to match image structure - request.setdefault("instances", [{}])[0]["lastFrame"] = { - "bytesBase64Encoded": encoded, - "mimeType": mime_type, - } - except (ValueError, IndexError, OSError): - msg = "Failed to process last_frame. Ensure valid data/path/url." - raise ValidationError(msg) from None + # Set lastFrame in instances[0] to match image structure + request.setdefault("instances", [{}])[0]["lastFrame"] = { + "bytesBase64Encoded": validated_value.get_base64(), + "mimeType": mime_str, + } return request diff --git a/src/celeste/providers/gradium/__init__.py b/src/celeste/providers/gradium/__init__.py new file mode 100644 index 00000000..4a7fadd5 --- /dev/null +++ b/src/celeste/providers/gradium/__init__.py @@ -0,0 +1,11 @@ +"""Gradium 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.GRADIUM, + secret_name="GRADIUM_API_KEY", + header="x-api-key", + prefix="", +) diff --git a/packages/providers/openai/src/celeste_openai/py.typed b/src/celeste/providers/gradium/py.typed similarity index 100% rename from packages/providers/openai/src/celeste_openai/py.typed rename to src/celeste/providers/gradium/py.typed diff --git a/packages/providers/gradium/src/celeste_gradium/text_to_speech/__init__.py b/src/celeste/providers/gradium/text_to_speech/__init__.py similarity index 100% rename from packages/providers/gradium/src/celeste_gradium/text_to_speech/__init__.py rename to src/celeste/providers/gradium/text_to_speech/__init__.py diff --git a/packages/providers/gradium/src/celeste_gradium/text_to_speech/client.py b/src/celeste/providers/gradium/text_to_speech/client.py similarity index 59% rename from packages/providers/gradium/src/celeste_gradium/text_to_speech/client.py rename to src/celeste/providers/gradium/text_to_speech/client.py index 49fe03a4..a7e4812a 100644 --- a/packages/providers/gradium/src/celeste_gradium/text_to_speech/client.py +++ b/src/celeste/providers/gradium/text_to_speech/client.py @@ -1,12 +1,14 @@ -"""Gradium Text-to-Speech API client with shared implementation.""" +"""Gradium TextToSpeech API client mixin.""" import base64 import json +from collections.abc import AsyncIterator from typing import Any from websockets.asyncio.client import connect as ws_connect from celeste.client import APIMixin +from celeste.io import FinishReason from celeste.mime_types import AudioMimeType from . import config @@ -16,7 +18,7 @@ class GradiumTextToSpeechClient(APIMixin): """Mixin for Gradium Text-to-Speech API. Provides shared implementation for speech generation via WebSocket: - - _websocket_tts() - Execute WebSocket TTS flow + - _make_stream_request() - WebSocket streaming (yields events) - _parse_usage() - Returns empty dict (TTS doesn't return usage) - _map_output_format_to_mime_type() - Map format string to AudioMimeType @@ -27,28 +29,28 @@ class GradiumTextToSpeechClient(APIMixin): 4. Send text to synthesize 5. Receive audio chunks (base64 encoded) 6. Receive end message - - Subclasses override generate() to use _websocket_tts() instead of HTTP. - - Usage: - class GradiumSpeechGenerationClient(GradiumTextToSpeechClient, SpeechGenerationClient): - async def generate(self, *args, **parameters): - # Build request... - audio_data, format = await self._websocket_tts(request_body) - # Return SpeechGenerationOutput... """ - async def _websocket_tts( + async def _make_stream_request( self, request_body: dict[str, Any], - ) -> tuple[bytes, str]: - """Execute WebSocket TTS flow. + *, + endpoint: str | None = None, + **parameters: Any, + ) -> AsyncIterator[dict[str, Any]]: + """Execute WebSocket TTS flow as async generator. + + Yields events in streaming format: + - {"data": bytes} for audio chunks + - {"finish_reason": "stop"} at end Args: request_body: Request with text, voice_id, output_format, json_config. + endpoint: WebSocket endpoint path (defaults to config). + **parameters: Additional parameters (unused). - Returns: - Tuple of (audio_bytes, output_format). + Yields: + Event dicts with audio data or finish_reason. Raises: ValueError: If connection fails or error received. @@ -58,7 +60,9 @@ async def _websocket_tts( text = request_body.get("text", "") json_config = request_body.get("json_config") - url = f"{config.BASE_URL}{config.GradiumTextToSpeechEndpoint.CREATE_SPEECH}" + if endpoint is None: + endpoint = config.GradiumTextToSpeechEndpoint.CREATE_SPEECH + url = f"{config.BASE_URL}{endpoint}" headers = self.auth.get_headers() async with ws_connect(url, additional_headers=headers) as ws: @@ -87,8 +91,7 @@ async def _websocket_tts( # 4. Signal end of input await ws.send(json.dumps({"type": "end_of_stream"})) - # 5. Collect audio chunks - audio_chunks: list[bytes] = [] + # 5. Yield audio chunks async for message in ws: if isinstance(message, bytes): data = json.loads(message.decode("utf-8")) @@ -96,36 +99,73 @@ async def _websocket_tts( data = json.loads(message) if data["type"] == "audio": - audio_chunks.append(base64.b64decode(data["audio"])) + yield {"data": base64.b64decode(data["audio"])} elif data["type"] == "end_of_stream": + yield {"finish_reason": "stop"} break elif data["type"] == "error": error_msg = data.get("message", "Unknown error") msg = f"Gradium TTS error: {error_msg}" raise ValueError(msg) - return b"".join(audio_chunks), output_format + @staticmethod + def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | float | None]: + """Map Gradium usage fields to unified names. + + Gradium TTS doesn't provide usage metadata. + """ + return {} - def _parse_usage(self, response_data: dict[str, Any]) -> dict[str, Any]: + def _parse_usage( + self, response_data: dict[str, Any] + ) -> dict[str, int | float | None]: """Extract usage data from Gradium API response. Gradium TTS doesn't provide usage metadata. Returns empty dict for capability clients to wrap in Usage type. """ - return {} + return GradiumTextToSpeechClient.map_usage_fields(response_data) + + def _parse_content(self, response_data: dict[str, Any]) -> Any: + """Parse content from Gradium TTS response. + + Gradium uses WebSocket; base generate() should not call this. + """ + msg = "Gradium TTS uses WebSocket; capability client must override generate()" + raise NotImplementedError(msg) + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> FinishReason: + """Gradium TTS doesn't provide finish reasons.""" + return FinishReason(reason=None) async def _make_request( self, request_body: dict[str, Any], + *, + endpoint: str | None = None, **parameters: Any, - ) -> Any: - """Make HTTP request - not used for Gradium (uses WebSocket). + ) -> dict[str, Any]: + """Collect audio from WebSocket stream. - Gradium TTS uses WebSocket via _websocket_tts(). - This method satisfies the abstract interface but should not be called. + Calls _make_stream_request() and aggregates all audio chunks. """ - msg = "Gradium TTS uses WebSocket, use _websocket_tts() instead" - raise NotImplementedError(msg) + audio_chunks: list[bytes] = [] + output_format = request_body.get("output_format", "wav") + + async for event in self._make_stream_request( + request_body, endpoint=endpoint, **parameters + ): + if "data" in event: + audio_chunks.append(event["data"]) + + return { + "audio_bytes": b"".join(audio_chunks), + "output_format": output_format, + } + + def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: + """Build metadata dictionary from response data.""" + return super()._build_metadata(response_data) def _map_output_format_to_mime_type( self, diff --git a/packages/providers/gradium/src/celeste_gradium/text_to_speech/config.py b/src/celeste/providers/gradium/text_to_speech/config.py similarity index 84% rename from packages/providers/gradium/src/celeste_gradium/text_to_speech/config.py rename to src/celeste/providers/gradium/text_to_speech/config.py index 7db027f6..0cd72f1e 100644 --- a/packages/providers/gradium/src/celeste_gradium/text_to_speech/config.py +++ b/src/celeste/providers/gradium/text_to_speech/config.py @@ -4,7 +4,7 @@ class GradiumTextToSpeechEndpoint(StrEnum): - """Endpoints for Text-to-Speech API.""" + """Endpoints for Gradium Text-to-Speech API.""" CREATE_SPEECH = "/speech/tts" diff --git a/packages/providers/gradium/src/celeste_gradium/text_to_speech/parameters.py b/src/celeste/providers/gradium/text_to_speech/parameters.py similarity index 97% rename from packages/providers/gradium/src/celeste_gradium/text_to_speech/parameters.py rename to src/celeste/providers/gradium/text_to_speech/parameters.py index 71f79dec..0c4b5588 100644 --- a/packages/providers/gradium/src/celeste_gradium/text_to_speech/parameters.py +++ b/src/celeste/providers/gradium/text_to_speech/parameters.py @@ -1,4 +1,4 @@ -"""Gradium TTS API parameter mappers.""" +"""Gradium TextToSpeech API parameter mappers.""" from typing import Any diff --git a/src/celeste/providers/gradium/text_to_speech/streaming.py b/src/celeste/providers/gradium/text_to_speech/streaming.py new file mode 100644 index 00000000..baaf6365 --- /dev/null +++ b/src/celeste/providers/gradium/text_to_speech/streaming.py @@ -0,0 +1,59 @@ +"""Gradium TextToSpeech WebSocket parsing for streaming.""" + +from typing import Any + +from celeste.io import FinishReason + + +class GradiumTextToSpeechStream: + """Mixin for TextToSpeech WebSocket stream parsing. + + Provides shared implementation for streaming parsing (provider API level): + - _parse_chunk_content(event_data) - Extract audio bytes from WebSocket event + - _parse_chunk_usage(event_data) - Extract and normalize usage from event + - _parse_chunk_finish_reason(event_data) - Extract finish reason from event + + Gradium TTS streams audio via WebSocket, yielding {"data": bytes} events. + + Modality streams call super() methods which resolve to this via MRO. + """ + + def _parse_chunk_content(self, event_data: dict[str, Any]) -> bytes | None: + """Extract audio bytes from event. + + Returns audio bytes if present, None otherwise. + """ + return event_data.get("data") + + def _parse_chunk_usage( + self, event_data: dict[str, Any] + ) -> dict[str, int | float | None] | None: + """Extract and normalize usage from event. + + Gradium TTS doesn't return usage in stream. + """ + return None + + def _parse_chunk_finish_reason( + self, event_data: dict[str, Any] + ) -> FinishReason | None: + """Extract finish reason from WebSocket event. + + Returns FinishReason if end_of_stream received. + """ + finish_reason = event_data.get("finish_reason") + if finish_reason: + return FinishReason(reason=finish_reason) + return None + + def _build_stream_metadata( + self, raw_events: list[dict[str, Any]] + ) -> dict[str, Any]: + """Filter to keep only metadata events. + + Gradium WebSocket stream has no metadata events. + """ + return super()._build_stream_metadata(raw_events) # type: ignore[misc] + + +__all__ = ["GradiumTextToSpeechStream"] diff --git a/src/celeste/providers/groq/__init__.py b/src/celeste/providers/groq/__init__.py new file mode 100644 index 00000000..771f23d4 --- /dev/null +++ b/src/celeste/providers/groq/__init__.py @@ -0,0 +1,11 @@ +"""Groq 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.GROQ, + secret_name="GROQ_API_KEY", + header="Authorization", + prefix="Bearer ", +) diff --git a/src/celeste/providers/groq/chat/__init__.py b/src/celeste/providers/groq/chat/__init__.py new file mode 100644 index 00000000..7144cde3 --- /dev/null +++ b/src/celeste/providers/groq/chat/__init__.py @@ -0,0 +1 @@ +"""Groq Chat API provider package.""" diff --git a/src/celeste/providers/groq/chat/client.py b/src/celeste/providers/groq/chat/client.py new file mode 100644 index 00000000..571d3b55 --- /dev/null +++ b/src/celeste/providers/groq/chat/client.py @@ -0,0 +1,145 @@ +"""Groq Chat API client mixin.""" + +from collections.abc import AsyncIterator +from typing import Any + +from celeste.client import APIMixin +from celeste.core import UsageField +from celeste.io import FinishReason +from celeste.mime_types import ApplicationMimeType + +from . import config + + +class GroqChatClient(APIMixin): + """Mixin for Groq Chat API capabilities. + + Provides shared implementation for all capabilities using the Chat API: + - _make_request() - HTTP POST to /openai/v1/chat/completions + - _make_stream_request() - HTTP streaming to /openai/v1/chat/completions + - _parse_usage() - Extract usage dict from response + - _parse_content() - Extract choices array from response + - _parse_finish_reason() - Extract finish reason from response + - _build_metadata() - Filter content fields + + Usage: + class GroqTextGenerationClient(GroqChatClient, TextGenerationClient): + def _parse_content(self, response_data, **parameters): + choices = super()._parse_content(response_data) + message = choices[0].get("message", {}) + content = message.get("content") or "" + return self._transform_output(content, **parameters) + """ + + def _build_request( + self, + inputs: Any, + extra_body: dict[str, Any] | None = None, + streaming: bool = False, + **parameters: Any, + ) -> dict[str, Any]: + """Build request with model ID and streaming flag.""" + request_body = super()._build_request( + inputs, extra_body=extra_body, streaming=streaming, **parameters + ) + request_body["model"] = self.model.id + if streaming: + request_body["stream"] = True + return request_body + + async def _make_request( + self, + request_body: dict[str, Any], + *, + endpoint: str | None = None, + **parameters: Any, + ) -> dict[str, Any]: + """Make HTTP request to Groq Chat API endpoint.""" + if endpoint is None: + endpoint = config.GroqChatEndpoint.CREATE_CHAT_COMPLETION + + headers = { + **self.auth.get_headers(), + "Content-Type": ApplicationMimeType.JSON, + } + + response = await self.http_client.post( + f"{config.BASE_URL}{endpoint}", + headers=headers, + json_body=request_body, + ) + self._handle_error_response(response) + data: dict[str, Any] = response.json() + return data + + def _make_stream_request( + self, + request_body: dict[str, Any], + *, + endpoint: str | None = None, + **parameters: Any, + ) -> AsyncIterator[dict[str, Any]]: + """Make streaming request to Groq Chat API endpoint.""" + if endpoint is None: + endpoint = config.GroqChatEndpoint.CREATE_CHAT_COMPLETION + + headers = { + **self.auth.get_headers(), + "Content-Type": ApplicationMimeType.JSON, + } + + return self.http_client.stream_post( + f"{config.BASE_URL}{endpoint}", + headers=headers, + json_body=request_body, + ) + + @staticmethod + def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | float | None]: + """Map Groq usage fields to unified names. + + Shared by client and streaming across all capabilities. + """ + return { + UsageField.INPUT_TOKENS: usage_data.get("prompt_tokens"), + UsageField.OUTPUT_TOKENS: usage_data.get("completion_tokens"), + UsageField.TOTAL_TOKENS: usage_data.get("total_tokens"), + } + + def _parse_usage( + self, response_data: dict[str, Any] + ) -> dict[str, int | float | None]: + """Extract usage data from Chat API response.""" + usage_data = response_data.get("usage", {}) + return GroqChatClient.map_usage_fields(usage_data) + + def _parse_content(self, response_data: dict[str, Any]) -> Any: + """Parse choices array from Chat API response. + + Returns raw choices array that capability clients extract from. + """ + choices = response_data.get("choices", []) + if not choices: + msg = "No choices in response" + raise ValueError(msg) + return choices + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> FinishReason: + """Extract finish reason from Chat API response.""" + choices = response_data.get("choices", []) + if not choices: + reason = None + else: + reason = choices[0].get("finish_reason") + return FinishReason(reason=reason) + + def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: + """Build metadata dictionary, filtering out content fields.""" + content_fields = {"choices"} + filtered_data = { + k: v for k, v in response_data.items() if k not in content_fields + } + return super()._build_metadata(filtered_data) + + +__all__ = ["GroqChatClient"] diff --git a/src/celeste/providers/groq/chat/config.py b/src/celeste/providers/groq/chat/config.py new file mode 100644 index 00000000..8482d794 --- /dev/null +++ b/src/celeste/providers/groq/chat/config.py @@ -0,0 +1,14 @@ +"""Configuration for Groq Chat API.""" + +from enum import StrEnum + + +class GroqChatEndpoint(StrEnum): + """Endpoints for Groq Chat API.""" + + CREATE_CHAT_COMPLETION = "/openai/v1/chat/completions" + LIST_MODELS = "/openai/v1/models" + GET_MODEL = "/openai/v1/models/{model_id}" + + +BASE_URL = "https://api.groq.com" diff --git a/src/celeste/providers/groq/chat/parameters.py b/src/celeste/providers/groq/chat/parameters.py new file mode 100644 index 00000000..8b409530 --- /dev/null +++ b/src/celeste/providers/groq/chat/parameters.py @@ -0,0 +1,125 @@ +"""Groq Chat API parameter mappers.""" + +import json +from typing import Any, get_args, get_origin + +from pydantic import BaseModel, TypeAdapter + +from celeste.models import Model +from celeste.parameters import ParameterMapper +from celeste.structured_outputs import StrictJsonSchemaGenerator +from celeste.types import TextContent + + +class TemperatureMapper(ParameterMapper): + """Map temperature to Groq temperature field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform temperature into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["temperature"] = validated_value + return request + + +class MaxTokensMapper(ParameterMapper): + """Map max_tokens to Groq max_tokens field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform max_tokens into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["max_tokens"] = validated_value + return request + + +class ResponseFormatMapper(ParameterMapper): + """Map output_schema to Groq response_format field (json_schema mode). + + Handles both single BaseModel and list[BaseModel] types. + Groq requires top-level object, so lists are wrapped in {items: []}. + Uses json_schema mode with strict: false for Llama 4 models. + """ + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform output_schema into Groq 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: + # Groq 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": False, # Groq requires strict: false for Llama 4 + "schema": schema, + }, + } + return request + + def parse_output(self, content: TextContent, value: object | None) -> TextContent: + """Parse JSON to BaseModel using Pydantic's TypeAdapter.""" + if value is None: + return content + + # If content is already a BaseModel, return it unchanged + if isinstance(content, BaseModel): + return content + if isinstance(content, list) and content and isinstance(content[0], BaseModel): + return content + + parsed_json = json.loads(content) if isinstance(content, str) else content + + # Unwrap list from items wrapper + origin = get_origin(value) + if origin is list and isinstance(parsed_json, dict) and "items" in parsed_json: + parsed_json = parsed_json["items"] + + return TypeAdapter(value).validate_python(parsed_json) + + +__all__ = [ + "MaxTokensMapper", + "ResponseFormatMapper", + "TemperatureMapper", +] diff --git a/src/celeste/providers/groq/chat/streaming.py b/src/celeste/providers/groq/chat/streaming.py new file mode 100644 index 00000000..3151a818 --- /dev/null +++ b/src/celeste/providers/groq/chat/streaming.py @@ -0,0 +1,81 @@ +"""Groq Chat SSE parsing for streaming.""" + +from typing import Any + +from celeste.io import FinishReason + +from .client import GroqChatClient + + +class GroqChatStream: + """Mixin for Chat API SSE parsing. + + Provides shared implementation for streaming parsing (provider API level): + - _parse_chunk_content(event_data) - Extract content from SSE event + - _parse_chunk_usage(event_data) - Extract and normalize usage from SSE event + - _parse_chunk_finish_reason(event_data) - Extract finish reason from SSE event + + Modality streams call super() methods which resolve to this via MRO. + """ + + def _parse_chunk_content(self, event_data: dict[str, Any]) -> str | None: + """Extract content from SSE event.""" + object_type = event_data.get("object") + if object_type != "chat.completion.chunk": + return None + + choices = event_data.get("choices", []) + if not choices: + return None + + first_choice = choices[0] + if not isinstance(first_choice, dict): + return None + + delta = first_choice.get("delta", {}) + if not isinstance(delta, dict): + return None + + return delta.get("content") + + def _parse_chunk_usage( + self, event_data: dict[str, Any] + ) -> dict[str, int | float | None] | None: + """Extract and normalize usage from SSE event.""" + usage_data = event_data.get("usage") + if isinstance(usage_data, dict): + return GroqChatClient.map_usage_fields(usage_data) + + return None + + def _parse_chunk_finish_reason( + self, event_data: dict[str, Any] + ) -> FinishReason | None: + """Extract finish reason from SSE event.""" + object_type = event_data.get("object") + if object_type != "chat.completion.chunk": + return None + + choices = event_data.get("choices", []) + if not choices: + return None + + first_choice = choices[0] + if not isinstance(first_choice, dict): + return None + + finish_reason = first_choice.get("finish_reason") + if finish_reason: + return FinishReason(reason=finish_reason) + + return None + + def _build_stream_metadata( + self, raw_events: list[dict[str, Any]] + ) -> dict[str, Any]: + """Filter content-only events for size efficiency (content is in Output.content).""" + filtered = [event for event in raw_events if event.get("usage")] + return super()._build_stream_metadata(filtered) # type: ignore[misc] + + +__all__ = ["GroqChatStream"] diff --git a/packages/providers/xai/src/celeste_xai/py.typed b/src/celeste/providers/groq/py.typed similarity index 100% rename from packages/providers/xai/src/celeste_xai/py.typed rename to src/celeste/providers/groq/py.typed diff --git a/src/celeste/providers/mistral/__init__.py b/src/celeste/providers/mistral/__init__.py new file mode 100644 index 00000000..cf005898 --- /dev/null +++ b/src/celeste/providers/mistral/__init__.py @@ -0,0 +1,12 @@ +"""Mistral provider package for Celeste AI.""" + +from celeste.core import Provider +from celeste.credentials import register_auth + +# Register Mistral auth config when package is imported +register_auth( # nosec B106 - env var name, not hardcoded password + provider=Provider.MISTRAL, + secret_name="MISTRAL_API_KEY", + header="Authorization", + prefix="Bearer ", +) diff --git a/packages/providers/mistral/src/celeste_mistral/chat/__init__.py b/src/celeste/providers/mistral/chat/__init__.py similarity index 100% rename from packages/providers/mistral/src/celeste_mistral/chat/__init__.py rename to src/celeste/providers/mistral/chat/__init__.py diff --git a/packages/providers/mistral/src/celeste_mistral/chat/client.py b/src/celeste/providers/mistral/chat/client.py similarity index 65% rename from packages/providers/mistral/src/celeste_mistral/chat/client.py rename to src/celeste/providers/mistral/chat/client.py index 6cc026cc..db23247b 100644 --- a/packages/providers/mistral/src/celeste_mistral/chat/client.py +++ b/src/celeste/providers/mistral/chat/client.py @@ -1,10 +1,8 @@ -"""Mistral Chat API client with shared implementation.""" +"""Mistral Chat API client mixin.""" from collections.abc import AsyncIterator from typing import Any -import httpx - from celeste.client import APIMixin from celeste.core import UsageField from celeste.io import FinishReason @@ -19,44 +17,71 @@ class MistralChatClient(APIMixin): Provides shared implementation for chat-based capabilities: - _make_request() - HTTP POST to /v1/chat/completions - _make_stream_request() - SSE streaming to /v1/chat/completions - - Capability clients extend parsing methods via super() to wrap/transform results. + - _parse_usage() - Extract usage dict from response + - _parse_content() - Extract choices from response + - _parse_finish_reason() - Extract finish reason from response + - _build_metadata() - Filter content fields Usage: class MistralTextGenerationClient(MistralChatClient, TextGenerationClient): def _parse_content(self, response_data, **parameters): - choices = response_data.get("choices", []) - text = choices[0]["message"]["content"] - return self._transform_output(text, **parameters) + choices = super()._parse_content(response_data) + message = choices[0].get("message", {}) + content = message.get("content") or "" + return self._transform_output(content, **parameters) """ + def _build_request( + self, + inputs: Any, + extra_body: dict[str, Any] | None = None, + streaming: bool = False, + **parameters: Any, + ) -> dict[str, Any]: + """Build request with model ID and streaming flag.""" + request_body = super()._build_request( + inputs, extra_body=extra_body, streaming=streaming, **parameters + ) + request_body["model"] = self.model.id + if streaming: + request_body["stream"] = True + return request_body + async def _make_request( self, request_body: dict[str, Any], + *, + endpoint: str | None = None, **parameters: Any, - ) -> httpx.Response: + ) -> dict[str, Any]: """Make HTTP request to Mistral Chat API.""" - request_body["model"] = self.model.id + if endpoint is None: + endpoint = config.MistralChatEndpoint.CREATE_CHAT_COMPLETION headers = { **self.auth.get_headers(), "Content-Type": ApplicationMimeType.JSON, } - return await self.http_client.post( - f"{config.BASE_URL}{config.MistralChatEndpoint.CREATE_CHAT_COMPLETION}", + response = await self.http_client.post( + f"{config.BASE_URL}{endpoint}", headers=headers, json_body=request_body, ) + self._handle_error_response(response) + data: dict[str, Any] = response.json() + return data def _make_stream_request( self, request_body: dict[str, Any], + *, + endpoint: str | None = None, **parameters: Any, ) -> AsyncIterator[dict[str, Any]]: """Make HTTP streaming request to Mistral Chat API.""" - request_body["model"] = self.model.id - request_body["stream"] = True + if endpoint is None: + endpoint = config.MistralChatEndpoint.CREATE_CHAT_COMPLETION headers = { **self.auth.get_headers(), @@ -64,13 +89,13 @@ def _make_stream_request( } return self.http_client.stream_post( - f"{config.BASE_URL}{config.MistralChatEndpoint.CREATE_CHAT_COMPLETION}", + f"{config.BASE_URL}{endpoint}", headers=headers, json_body=request_body, ) @staticmethod - def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | None]: + def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | float | None]: """Map Mistral usage fields to unified names. Shared by client and streaming across all capabilities. @@ -81,7 +106,9 @@ def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | None]: UsageField.TOTAL_TOKENS: usage_data.get("total_tokens"), } - def _parse_usage(self, response_data: dict[str, Any]) -> dict[str, int | None]: + def _parse_usage( + self, response_data: dict[str, Any] + ) -> dict[str, int | float | None]: """Extract usage data from response.""" usage_data = response_data.get("usage", {}) return MistralChatClient.map_usage_fields(usage_data) diff --git a/packages/providers/mistral/src/celeste_mistral/chat/config.py b/src/celeste/providers/mistral/chat/config.py similarity index 97% rename from packages/providers/mistral/src/celeste_mistral/chat/config.py rename to src/celeste/providers/mistral/chat/config.py index 27f3af68..4b32ff4a 100644 --- a/packages/providers/mistral/src/celeste_mistral/chat/config.py +++ b/src/celeste/providers/mistral/chat/config.py @@ -4,7 +4,7 @@ class MistralChatEndpoint(StrEnum): - """Endpoints for Chat API.""" + """Endpoints for Mistral Chat API.""" CREATE_CHAT_COMPLETION = "/v1/chat/completions" CREATE_FIM_COMPLETION = "/v1/fim/completions" diff --git a/packages/providers/mistral/src/celeste_mistral/chat/parameters.py b/src/celeste/providers/mistral/chat/parameters.py similarity index 79% rename from packages/providers/mistral/src/celeste_mistral/chat/parameters.py rename to src/celeste/providers/mistral/chat/parameters.py index ead366f1..a7f829ae 100644 --- a/packages/providers/mistral/src/celeste_mistral/chat/parameters.py +++ b/src/celeste/providers/mistral/chat/parameters.py @@ -8,7 +8,7 @@ from celeste.models import Model from celeste.parameters import ParameterMapper from celeste.structured_outputs import StrictRefResolvingJsonSchemaGenerator -from celeste.types import StructuredOutput +from celeste.types import TextContent class TemperatureMapper(ParameterMapper): @@ -47,8 +47,26 @@ def map( return request -class OutputSchemaMapper(ParameterMapper): - """Map output_schema to Mistral structured outputs format. +class WebSearchMapper(ParameterMapper): + """Map web_search to Mistral tools field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform web_search into provider request.""" + validated_value = self._validate_value(value, model) + if not validated_value: + return request + + request.setdefault("tools", []).append({"type": "web_search"}) + return request + + +class ResponseFormatMapper(ParameterMapper): + """Map output_schema to Mistral response_format field. Handles both single BaseModel and list[BaseModel] types. Lists are returned as arrays directly (no wrapping needed). @@ -91,9 +109,7 @@ def map( } return request - def parse_output( - self, content: StructuredOutput, value: object | None - ) -> StructuredOutput: + def parse_output(self, content: TextContent, value: object | None) -> TextContent: """Parse JSON to BaseModel using Pydantic's TypeAdapter.""" if value is None: return content @@ -111,4 +127,9 @@ def parse_output( return TypeAdapter(value).validate_python(parsed) -__all__ = ["MaxTokensMapper", "OutputSchemaMapper", "TemperatureMapper"] +__all__ = [ + "MaxTokensMapper", + "ResponseFormatMapper", + "TemperatureMapper", + "WebSearchMapper", +] diff --git a/src/celeste/providers/mistral/chat/streaming.py b/src/celeste/providers/mistral/chat/streaming.py new file mode 100644 index 00000000..584b7073 --- /dev/null +++ b/src/celeste/providers/mistral/chat/streaming.py @@ -0,0 +1,83 @@ +"""Mistral Chat SSE parsing for streaming.""" + +from typing import Any + +from celeste.io import FinishReason + +from .client import MistralChatClient + + +class MistralChatStream: + """Mixin for Chat API SSE parsing. + + Provides shared implementation for streaming parsing (provider API level): + - _parse_chunk_content(event_data) - Extract content from SSE event + - _parse_chunk_usage(event_data) - Extract and normalize usage from SSE event + - _parse_chunk_finish_reason(event_data) - Extract finish reason from SSE event + + Modality streams call super() methods which resolve to this via MRO. + """ + + def _parse_chunk_content(self, event_data: dict[str, Any]) -> str | None: + """Extract content from SSE event.""" + choices = event_data.get("choices", []) + if not choices: + return None + + first_choice = choices[0] + if not isinstance(first_choice, dict): + return None + + delta = first_choice.get("delta", {}) + if not isinstance(delta, dict): + return None + + content_delta = delta.get("content") + + # Handle magistral thinking models that may return list content + if isinstance(content_delta, list): + text_parts = [] + for block in content_delta: + if isinstance(block, dict) and block.get("type") == "text": + text_parts.append(block.get("text", "")) + return "".join(text_parts) if text_parts else None + + return content_delta + + def _parse_chunk_usage( + self, event_data: dict[str, Any] + ) -> dict[str, int | float | None] | None: + """Extract and normalize usage from SSE event.""" + usage_data = event_data.get("usage") + if isinstance(usage_data, dict): + return MistralChatClient.map_usage_fields(usage_data) + + return None + + def _parse_chunk_finish_reason( + self, event_data: dict[str, Any] + ) -> FinishReason | None: + """Extract finish reason from SSE event.""" + choices = event_data.get("choices", []) + if not choices: + return None + + first_choice = choices[0] + if not isinstance(first_choice, dict): + return None + + finish_reason = first_choice.get("finish_reason") + if finish_reason: + return FinishReason(reason=finish_reason) + + return None + + def _build_stream_metadata( + self, raw_events: list[dict[str, Any]] + ) -> dict[str, Any]: + """Filter content-only events for size efficiency (content is in Output.content).""" + filtered = [event for event in raw_events if event.get("usage")] + return super()._build_stream_metadata(filtered) # type: ignore[misc] + + +__all__ = ["MistralChatStream"] diff --git a/packages/capabilities/speech-generation/tests/integration_tests/__init__.py b/src/celeste/providers/mistral/py.typed similarity index 100% rename from packages/capabilities/speech-generation/tests/integration_tests/__init__.py rename to src/celeste/providers/mistral/py.typed diff --git a/src/celeste/providers/moonshot/__init__.py b/src/celeste/providers/moonshot/__init__.py new file mode 100644 index 00000000..3d29ecf2 --- /dev/null +++ b/src/celeste/providers/moonshot/__init__.py @@ -0,0 +1,12 @@ +"""Moonshot provider package for Celeste AI.""" + +from celeste.core import Provider +from celeste.credentials import register_auth + +# Register Moonshot auth config when package is imported +register_auth( # nosec B106 - env var name, not hardcoded password + provider=Provider.MOONSHOT, + secret_name="MOONSHOT_API_KEY", + header="Authorization", + prefix="Bearer ", +) diff --git a/src/celeste/providers/moonshot/chat/__init__.py b/src/celeste/providers/moonshot/chat/__init__.py new file mode 100644 index 00000000..cd1dc54d --- /dev/null +++ b/src/celeste/providers/moonshot/chat/__init__.py @@ -0,0 +1 @@ +"""Moonshot Chat API provider package.""" diff --git a/src/celeste/providers/moonshot/chat/client.py b/src/celeste/providers/moonshot/chat/client.py new file mode 100644 index 00000000..9237ccbf --- /dev/null +++ b/src/celeste/providers/moonshot/chat/client.py @@ -0,0 +1,143 @@ +"""Moonshot Chat API client mixin.""" + +from collections.abc import AsyncIterator +from typing import Any + +from celeste.client import APIMixin +from celeste.core import UsageField +from celeste.io import FinishReason +from celeste.mime_types import ApplicationMimeType + +from . import config + + +class MoonshotChatClient(APIMixin): + """Mixin for Moonshot Chat API. + + Provides shared implementation for chat-based capabilities: + - _make_request() - HTTP POST to /v1/chat/completions + - _make_stream_request() - SSE streaming with usage inclusion + - _parse_usage() - Extract usage from response + - _parse_content() - Extract choices from response + - _parse_finish_reason() - Extract finish_reason from choices[0] + - _build_metadata() - Filter content fields + + Usage: + class MoonshotTextGenerationClient(MoonshotChatClient, TextGenerationClient): + def _parse_content(self, response_data, **parameters): + choices = super()._parse_content(response_data) + message = choices[0].get("message", {}) + content = message.get("content") or "" + return self._transform_output(content, **parameters) + """ + + def _build_request( + self, + inputs: Any, + extra_body: dict[str, Any] | None = None, + streaming: bool = False, + **parameters: Any, + ) -> dict[str, Any]: + """Build request with model ID and streaming flag.""" + request_body = super()._build_request( + inputs, extra_body=extra_body, streaming=streaming, **parameters + ) + request_body["model"] = self.model.id + if streaming: + request_body["stream"] = True + request_body["stream_options"] = {"include_usage": True} + return request_body + + async def _make_request( + self, + request_body: dict[str, Any], + *, + endpoint: str | None = None, + **parameters: Any, + ) -> dict[str, Any]: + """Make HTTP request to Moonshot Chat endpoint.""" + if endpoint is None: + endpoint = config.MoonshotChatEndpoint.CREATE_CHAT + + headers = { + **self.auth.get_headers(), + "Content-Type": ApplicationMimeType.JSON, + } + + response = await self.http_client.post( + f"{config.BASE_URL}{endpoint}", + headers=headers, + json_body=request_body, + ) + self._handle_error_response(response) + data: dict[str, Any] = response.json() + return data + + def _make_stream_request( + self, + request_body: dict[str, Any], + *, + endpoint: str | None = None, + **parameters: Any, + ) -> AsyncIterator[dict[str, Any]]: + """Make HTTP streaming request returning SSE events.""" + if endpoint is None: + endpoint = config.MoonshotChatEndpoint.CREATE_CHAT + + headers = { + **self.auth.get_headers(), + "Content-Type": ApplicationMimeType.JSON, + } + + return self.http_client.stream_post( + f"{config.BASE_URL}{endpoint}", + headers=headers, + json_body=request_body, + ) + + @staticmethod + def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | float | None]: + """Map Moonshot usage fields to unified names. + + Shared by client and streaming across all capabilities. + """ + return { + UsageField.INPUT_TOKENS: usage_data.get("prompt_tokens"), + UsageField.OUTPUT_TOKENS: usage_data.get("completion_tokens"), + UsageField.TOTAL_TOKENS: usage_data.get("total_tokens"), + } + + def _parse_usage( + self, response_data: dict[str, Any] + ) -> dict[str, int | float | None]: + """Extract usage data from response.""" + usage_data = response_data.get("usage", {}) + return MoonshotChatClient.map_usage_fields(usage_data) + + def _parse_content(self, response_data: dict[str, Any]) -> Any: + """Return choices from response.""" + choices = response_data.get("choices", []) + if not choices: + msg = "No choices in response" + raise ValueError(msg) + return choices + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> FinishReason: + """Extract finish reason from choices.""" + choices = response_data.get("choices", []) + if not choices: + reason = None + else: + reason = choices[0].get("finish_reason") + return FinishReason(reason=reason) + + def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: + """Build metadata dictionary, filtering out content fields.""" + content_fields = {"choices"} + filtered_data = { + k: v for k, v in response_data.items() if k not in content_fields + } + return super()._build_metadata(filtered_data) + + +__all__ = ["MoonshotChatClient"] diff --git a/src/celeste/providers/moonshot/chat/config.py b/src/celeste/providers/moonshot/chat/config.py new file mode 100644 index 00000000..a513cee7 --- /dev/null +++ b/src/celeste/providers/moonshot/chat/config.py @@ -0,0 +1,12 @@ +"""Configuration for Moonshot Chat API.""" + +from enum import StrEnum + + +class MoonshotChatEndpoint(StrEnum): + """Endpoints for Moonshot Chat API.""" + + CREATE_CHAT = "/v1/chat/completions" + + +BASE_URL = "https://api.moonshot.ai" diff --git a/src/celeste/providers/moonshot/chat/parameters.py b/src/celeste/providers/moonshot/chat/parameters.py new file mode 100644 index 00000000..9e191d1c --- /dev/null +++ b/src/celeste/providers/moonshot/chat/parameters.py @@ -0,0 +1,97 @@ +"""Moonshot Chat API parameter mappers.""" + +import json +from typing import Any, get_origin + +from pydantic import BaseModel, TypeAdapter + +from celeste.models import Model +from celeste.parameters import ParameterMapper +from celeste.types import TextContent + + +class TemperatureMapper(ParameterMapper): + """Map temperature to Moonshot temperature field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform temperature into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["temperature"] = validated_value + return request + + +class MaxTokensMapper(ParameterMapper): + """Map max_tokens to Moonshot max_tokens field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform max_tokens into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["max_tokens"] = validated_value + return request + + +class ResponseFormatMapper(ParameterMapper): + """Map output_schema to Moonshot response_format field. + + Moonshot supports basic JSON mode only (no schema validation server-side). + Schema validation happens client-side via parse_output method. + """ + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform output_schema into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["response_format"] = {"type": "json_object"} + return request + + def parse_output(self, content: TextContent, value: object | None) -> TextContent: + """Parse JSON to BaseModel using Pydantic's TypeAdapter.""" + if value is None: + return content + + # If content is already a BaseModel, return it unchanged + if isinstance(content, BaseModel): + return content + if isinstance(content, list) and content and isinstance(content[0], BaseModel): + return content + + if isinstance(content, str): + parsed = json.loads(content) + else: + parsed = content + + # For list[T], handle various formats Moonshot might return + origin = get_origin(value) + if origin is list and isinstance(parsed, dict): + if "items" in parsed: + parsed = parsed["items"] + else: + parsed = list(parsed.values()) + + return TypeAdapter(value).validate_python(parsed) + + +__all__ = ["MaxTokensMapper", "ResponseFormatMapper", "TemperatureMapper"] diff --git a/src/celeste/providers/moonshot/chat/streaming.py b/src/celeste/providers/moonshot/chat/streaming.py new file mode 100644 index 00000000..33211514 --- /dev/null +++ b/src/celeste/providers/moonshot/chat/streaming.py @@ -0,0 +1,87 @@ +"""Moonshot Chat SSE parsing for streaming.""" + +from typing import Any + +from celeste.io import FinishReason + +from .client import MoonshotChatClient + + +class MoonshotChatStream: + """Mixin for Chat API SSE parsing. + + Provides shared implementation for streaming parsing (provider API level): + - _parse_chunk_content(event_data) - Extract content from SSE event + - _parse_chunk_usage(event_data) - Extract and normalize usage from SSE event + - _parse_chunk_finish_reason(event_data) - Extract finish reason from SSE event + + Modality streams call super() methods which resolve to this via MRO. + """ + + def _parse_chunk_content(self, event_data: dict[str, Any]) -> str | None: + """Extract content from SSE event.""" + object_type = event_data.get("object") + if object_type != "chat.completion.chunk": + return None + + choices = event_data.get("choices", []) + if not choices: + return None + + first_choice = choices[0] + if not isinstance(first_choice, dict): + return None + + delta = first_choice.get("delta", {}) + if not isinstance(delta, dict): + return None + + return delta.get("content") + + def _parse_chunk_usage( + self, event_data: dict[str, Any] + ) -> dict[str, int | float | None] | None: + """Extract and normalize usage from SSE event.""" + # Check both top-level and choices[0] (Moonshot's non-standard location) + usage_data = event_data.get("usage") + if not isinstance(usage_data, dict): + choices = event_data.get("choices", []) + if choices and isinstance(choices[0], dict): + usage_data = choices[0].get("usage") + + if isinstance(usage_data, dict): + return MoonshotChatClient.map_usage_fields(usage_data) + + return None + + def _parse_chunk_finish_reason( + self, event_data: dict[str, Any] + ) -> FinishReason | None: + """Extract finish reason from SSE event.""" + object_type = event_data.get("object") + if object_type != "chat.completion.chunk": + return None + + choices = event_data.get("choices", []) + if not choices: + return None + + first_choice = choices[0] + if not isinstance(first_choice, dict): + return None + + finish_reason = first_choice.get("finish_reason") + if finish_reason: + return FinishReason(reason=finish_reason) + + return None + + def _build_stream_metadata( + self, raw_events: list[dict[str, Any]] + ) -> dict[str, Any]: + """Filter content-only events for size efficiency (content is in Output.content).""" + filtered = [event for event in raw_events if event.get("usage")] + return super()._build_stream_metadata(filtered) # type: ignore[misc] + + +__all__ = ["MoonshotChatStream"] diff --git a/src/__init__.py b/src/celeste/providers/moonshot/py.typed similarity index 100% rename from src/__init__.py rename to src/celeste/providers/moonshot/py.typed diff --git a/src/celeste/providers/openai/__init__.py b/src/celeste/providers/openai/__init__.py new file mode 100644 index 00000000..528621a7 --- /dev/null +++ b/src/celeste/providers/openai/__init__.py @@ -0,0 +1,12 @@ +"""OpenAI provider package for Celeste AI.""" + +from celeste.core import Provider +from celeste.credentials import register_auth + +# Register OpenAI auth config when package is imported +register_auth( # nosec B106 - env var name, not hardcoded password + provider=Provider.OPENAI, + secret_name="OPENAI_API_KEY", + header="Authorization", + prefix="Bearer ", +) diff --git a/packages/providers/openai/src/celeste_openai/audio/__init__.py b/src/celeste/providers/openai/audio/__init__.py similarity index 100% rename from packages/providers/openai/src/celeste_openai/audio/__init__.py rename to src/celeste/providers/openai/audio/__init__.py diff --git a/packages/providers/openai/src/celeste_openai/audio/client.py b/src/celeste/providers/openai/audio/client.py similarity index 51% rename from packages/providers/openai/src/celeste_openai/audio/client.py rename to src/celeste/providers/openai/audio/client.py index 01519d83..ccce48b8 100644 --- a/packages/providers/openai/src/celeste_openai/audio/client.py +++ b/src/celeste/providers/openai/audio/client.py @@ -1,10 +1,11 @@ -"""OpenAI Audio API client with shared implementation.""" +"""OpenAI Audio API client mixin.""" +from collections.abc import AsyncIterator from typing import Any -import httpx - from celeste.client import APIMixin +from celeste.exceptions import StreamingNotSupportedError +from celeste.io import FinishReason from celeste.mime_types import ApplicationMimeType, AudioMimeType from . import config @@ -27,30 +28,64 @@ async def generate(self, *args, **parameters): # Handle binary response... """ + def _build_request( + self, + inputs: Any, + extra_body: dict[str, Any] | None = None, + streaming: bool = False, + **parameters: Any, + ) -> dict[str, Any]: + """Build request with model ID and streaming flag.""" + request_body = super()._build_request( + inputs, extra_body=extra_body, streaming=streaming, **parameters + ) + request_body["model"] = self.model.id + if streaming: + request_body["stream"] = True + return request_body + + def _make_stream_request( + self, + request_body: dict[str, Any], + *, + endpoint: str | None = None, + **parameters: Any, + ) -> AsyncIterator[dict[str, Any]]: + """OpenAI Audio API speech endpoint does not support SSE streaming in this client.""" + raise StreamingNotSupportedError(model_id=self.model.id) + async def _make_request( self, request_body: dict[str, Any], + *, + endpoint: str | None = None, **parameters: Any, - ) -> httpx.Response: + ) -> dict[str, Any]: """Make HTTP request to OpenAI Audio API speech endpoint. - Returns the raw response with binary audio content. + Returns dict with binary audio content. """ - request_body["model"] = self.model.id + if endpoint is None: + endpoint = config.OpenAIAudioEndpoint.CREATE_SPEECH headers = { **self.auth.get_headers(), "Content-Type": ApplicationMimeType.JSON, } - return await self.http_client.post( - f"{config.BASE_URL}{config.OpenAIAudioEndpoint.CREATE_SPEECH}", + response = await self.http_client.post( + f"{config.BASE_URL}{endpoint}", headers=headers, json_body=request_body, ) + self._handle_error_response(response) + return { + "audio_bytes": response.content, + "headers": dict(response.headers), + } @staticmethod - def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | None]: + def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | float | None]: """Map OpenAI Audio usage fields to unified names. Shared by client and streaming across all capabilities. @@ -59,10 +94,28 @@ def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | None]: """ return {} - def _parse_usage(self, response_data: dict[str, Any]) -> dict[str, int | None]: + def _parse_usage( + self, response_data: dict[str, Any] + ) -> dict[str, int | float | None]: """Extract usage data from Audio API response.""" return OpenAIAudioClient.map_usage_fields(response_data) + def _parse_content(self, response_data: dict[str, Any]) -> Any: + """Parse content from OpenAI Audio API response. + + The speech endpoint returns binary audio, so base generate() should not call this. + """ + msg = "OpenAI TTS returns binary responses; capability client must override generate()" + raise NotImplementedError(msg) + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> FinishReason: + """OpenAI Audio API doesn't provide finish reasons.""" + return FinishReason(reason=None) + + def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: + """Build metadata dictionary, filtering out content fields.""" + return super()._build_metadata(response_data) + def _map_response_format_to_mime_type( self, response_format: str | None ) -> AudioMimeType: diff --git a/packages/providers/openai/src/celeste_openai/audio/config.py b/src/celeste/providers/openai/audio/config.py similarity index 87% rename from packages/providers/openai/src/celeste_openai/audio/config.py rename to src/celeste/providers/openai/audio/config.py index 79369497..9f7b8b29 100644 --- a/packages/providers/openai/src/celeste_openai/audio/config.py +++ b/src/celeste/providers/openai/audio/config.py @@ -4,7 +4,7 @@ class OpenAIAudioEndpoint(StrEnum): - """Endpoints for Audio API.""" + """Endpoints for OpenAI Audio API.""" CREATE_SPEECH = "/v1/audio/speech" CREATE_TRANSCRIPTION = "/v1/audio/transcriptions" diff --git a/packages/providers/openai/src/celeste_openai/audio/parameters.py b/src/celeste/providers/openai/audio/parameters.py similarity index 100% rename from packages/providers/openai/src/celeste_openai/audio/parameters.py rename to src/celeste/providers/openai/audio/parameters.py diff --git a/packages/providers/openai/src/celeste_openai/images/__init__.py b/src/celeste/providers/openai/images/__init__.py similarity index 100% rename from packages/providers/openai/src/celeste_openai/images/__init__.py rename to src/celeste/providers/openai/images/__init__.py diff --git a/packages/providers/openai/src/celeste_openai/images/client.py b/src/celeste/providers/openai/images/client.py similarity index 53% rename from packages/providers/openai/src/celeste_openai/images/client.py rename to src/celeste/providers/openai/images/client.py index 1dce93c9..861eed7e 100644 --- a/packages/providers/openai/src/celeste_openai/images/client.py +++ b/src/celeste/providers/openai/images/client.py @@ -2,47 +2,73 @@ Provides shared implementation for capabilities using the OpenAI Images API: - image-generation (generations endpoint) +- image-edit (edits endpoint) """ from collections.abc import AsyncIterator from typing import Any -import httpx - from celeste.client import APIMixin from celeste.core import UsageField from celeste.io import FinishReason from celeste.mime_types import ApplicationMimeType +from celeste.utils import detect_mime_type from . import config class OpenAIImagesClient(APIMixin): - """Mixin for OpenAI Images API image generation. + """Mixin for OpenAI Images API. - Provides shared implementation for image generation: - - _make_request() - HTTP POST to /v1/images/generations - - _make_stream_request() - HTTP streaming to /v1/images/generations + Provides shared implementation for image operations: + - _make_request(endpoint=...) - HTTP POST to images endpoint + - _make_stream_request() - HTTP streaming - _parse_usage() - Extract usage dict from response - _parse_content() - Extract data array from response - _parse_finish_reason() - Returns None (Images API doesn't provide finish reasons) - _build_metadata() - Filter content fields and include revised_prompt - - Usage: - class OpenAIImageGenerationClient(OpenAIImagesClient, ImageGenerationClient): - def _parse_content(self, response_data, **parameters): - data = super()._parse_content(response_data) - # Extract image from data[0]... """ + def _build_request( + self, + inputs: Any, + extra_body: dict[str, Any] | None = None, + streaming: bool = False, + **parameters: Any, + ) -> dict[str, Any]: + """Build request with model ID and streaming flag.""" + request_body = super()._build_request( + inputs, extra_body=extra_body, streaming=streaming, **parameters + ) + request_body["model"] = self.model.id + if streaming: + request_body["stream"] = True + return request_body + async def _make_request( self, request_body: dict[str, Any], + *, + endpoint: str | None = None, **parameters: Any, - ) -> httpx.Response: - """Make HTTP request to OpenAI Images API generations endpoint.""" - request_body["model"] = self.model.id + ) -> dict[str, Any]: + """Make HTTP request to OpenAI Images API.""" + if endpoint is None: + endpoint = config.OpenAIImagesEndpoint.CREATE_IMAGE + + # Edit endpoint requires multipart/form-data + if endpoint == config.OpenAIImagesEndpoint.CREATE_EDIT: + return await self._make_multipart_request(request_body, endpoint) + # Generate uses JSON + return await self._make_json_request(request_body, endpoint) + + async def _make_json_request( + self, + request_body: dict[str, Any], + endpoint: str, + ) -> dict[str, Any]: + """Make JSON request for generate operations.""" # DALL-E 2/3 need b64_json response format if self.model.id in ("dall-e-2", "dall-e-3"): request_body.setdefault("response_format", "b64_json") @@ -52,23 +78,63 @@ async def _make_request( "Content-Type": ApplicationMimeType.JSON, } - return await self.http_client.post( - f"{config.BASE_URL}{config.OpenAIImagesEndpoint.CREATE_IMAGE}", + response = await self.http_client.post( + f"{config.BASE_URL}{endpoint}", headers=headers, json_body=request_body, ) + self._handle_error_response(response) + data: dict[str, Any] = response.json() + return data + + async def _make_multipart_request( + self, + request_body: dict[str, Any], + endpoint: str, + ) -> dict[str, Any]: + """Make multipart request for edit operations.""" + image_artifact = request_body.pop("image") + + # Get image bytes from artifact + image_bytes = image_artifact.get_bytes() + + # Detect MIME type if not explicitly set + mime = image_artifact.mime_type or detect_mime_type(image_bytes) + mime_str = mime.value if mime else "application/octet-stream" + + files = {"image": ("image", image_bytes, mime_str)} + # Model is already in request_body from _build_request() + model = request_body.pop("model") + data = {"model": model} + + # Add remaining fields as form data + for key, value in request_body.items(): + if value is not None: + data[key] = str(value) + + response = await self.http_client.post_multipart( + f"{config.BASE_URL}{endpoint}", + headers=self.auth.get_headers(), + files=files, + data=data, + ) + self._handle_error_response(response) + response_data: dict[str, Any] = response.json() + return response_data def _make_stream_request( self, request_body: dict[str, Any], + *, + endpoint: str | None = None, **parameters: Any, ) -> AsyncIterator[dict[str, Any]]: """Make streaming request to OpenAI Images API generations endpoint. Streaming is only supported for gpt-image-1. """ - request_body["model"] = self.model.id - request_body["stream"] = True + if endpoint is None: + endpoint = config.OpenAIImagesEndpoint.CREATE_IMAGE if "partial_images" not in request_body: request_body["partial_images"] = 1 @@ -79,13 +145,13 @@ def _make_stream_request( } return self.http_client.stream_post( - f"{config.BASE_URL}{config.OpenAIImagesEndpoint.CREATE_IMAGE}", + f"{config.BASE_URL}{endpoint}", headers=headers, json_body=request_body, ) @staticmethod - def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | None]: + def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | float | None]: """Map OpenAI Images usage fields to unified names. Shared by client and streaming across all capabilities. @@ -96,7 +162,9 @@ def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | None]: UsageField.TOTAL_TOKENS: usage_data.get("total_tokens"), } - def _parse_usage(self, response_data: dict[str, Any]) -> dict[str, int | None]: + def _parse_usage( + self, response_data: dict[str, Any] + ) -> dict[str, int | float | None]: """Extract usage data from Images API response. Returns dict that capability clients wrap in their specific Usage type. diff --git a/packages/providers/openai/src/celeste_openai/images/config.py b/src/celeste/providers/openai/images/config.py similarity index 86% rename from packages/providers/openai/src/celeste_openai/images/config.py rename to src/celeste/providers/openai/images/config.py index 93f558b6..74e74ea4 100644 --- a/packages/providers/openai/src/celeste_openai/images/config.py +++ b/src/celeste/providers/openai/images/config.py @@ -4,7 +4,7 @@ class OpenAIImagesEndpoint(StrEnum): - """Endpoints for Images API.""" + """Endpoints for OpenAI Images API.""" CREATE_IMAGE = "/v1/images/generations" CREATE_EDIT = "/v1/images/edits" diff --git a/packages/providers/openai/src/celeste_openai/images/parameters.py b/src/celeste/providers/openai/images/parameters.py similarity index 100% rename from packages/providers/openai/src/celeste_openai/images/parameters.py rename to src/celeste/providers/openai/images/parameters.py diff --git a/src/celeste/providers/openai/images/streaming.py b/src/celeste/providers/openai/images/streaming.py new file mode 100644 index 00000000..334cb787 --- /dev/null +++ b/src/celeste/providers/openai/images/streaming.py @@ -0,0 +1,88 @@ +"""OpenAI Images SSE parsing for streaming.""" + +from typing import Any + +from celeste.io import FinishReason + +from .client import OpenAIImagesClient + + +class OpenAIImagesStream: + """Mixin for Images API SSE parsing. + + Provides shared implementation for streaming parsing (provider API level): + - _parse_chunk_content(event_data) - Extract b64_json content from SSE event + - _parse_chunk_usage(event_data) - Extract and normalize usage from SSE event + - _parse_chunk_finish_reason(event_data) - Extract finish reason from SSE event + - _parse_chunk_metadata(event_data) - Extract image metadata from SSE event + + Handles all image streaming event types: + - image_generation.partial_image / image_generation.completed + - image_edit.partial_image / image_edit.completed + + Modality streams call super() methods which resolve to this via MRO. + """ + + def _parse_chunk_content(self, event_data: dict[str, Any]) -> str | None: + """Extract b64_json content from SSE event. + + Returns base64 encoded image string if present, None otherwise. + """ + event_type = event_data.get("type") + if not event_type: + return None + + if event_type in ( + "image_generation.partial_image", + "image_edit.partial_image", + "image_generation.completed", + "image_edit.completed", + ): + return event_data.get("b64_json") + + return None + + def _parse_chunk_usage( + self, event_data: dict[str, Any] + ) -> dict[str, int | float | None] | None: + """Extract and normalize usage from SSE event.""" + event_type = event_data.get("type") + + if event_type in ("image_generation.completed", "image_edit.completed"): + usage_data = event_data.get("usage") + if usage_data: + return OpenAIImagesClient.map_usage_fields(usage_data) + + return None + + def _parse_chunk_finish_reason( + self, event_data: dict[str, Any] + ) -> FinishReason | None: + """Extract finish reason from SSE event.""" + event_type = event_data.get("type") + + if event_type in ("image_generation.completed", "image_edit.completed"): + return FinishReason(reason="completed") + + return None + + def _parse_chunk_metadata(self, event_data: dict[str, Any]) -> dict[str, Any]: + """Extract image-specific metadata from SSE event.""" + return { + "size": event_data.get("size"), + "quality": event_data.get("quality"), + "output_format": event_data.get("output_format"), + "background": event_data.get("background"), + "created_at": event_data.get("created_at"), + "partial_image_index": event_data.get("partial_image_index"), + } + + def _build_stream_metadata( + self, raw_events: list[dict[str, Any]] + ) -> dict[str, Any]: + """Filter content-only events for size efficiency (content is in Output.content).""" + filtered = [e for e in raw_events if "partial_image" not in e.get("type", "")] + return super()._build_stream_metadata(filtered) # type: ignore[misc] + + +__all__ = ["OpenAIImagesStream"] diff --git a/src/celeste/providers/openai/py.typed b/src/celeste/providers/openai/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/packages/providers/openai/src/celeste_openai/responses/__init__.py b/src/celeste/providers/openai/responses/__init__.py similarity index 100% rename from packages/providers/openai/src/celeste_openai/responses/__init__.py rename to src/celeste/providers/openai/responses/__init__.py diff --git a/packages/providers/openai/src/celeste_openai/responses/client.py b/src/celeste/providers/openai/responses/client.py similarity index 79% rename from packages/providers/openai/src/celeste_openai/responses/client.py rename to src/celeste/providers/openai/responses/client.py index 9cf296d9..13498cf6 100644 --- a/packages/providers/openai/src/celeste_openai/responses/client.py +++ b/src/celeste/providers/openai/responses/client.py @@ -1,10 +1,8 @@ -"""OpenAI Responses API client with shared implementation.""" +"""OpenAI Responses API client mixin.""" from collections.abc import AsyncIterator from typing import Any -import httpx - from celeste.client import APIMixin from celeste.core import UsageField from celeste.io import FinishReason @@ -36,33 +34,57 @@ def _parse_content(self, response_data, **parameters): return "" """ + def _build_request( + self, + inputs: Any, + extra_body: dict[str, Any] | None = None, + streaming: bool = False, + **parameters: Any, + ) -> dict[str, Any]: + """Build request with model ID and streaming flag.""" + request_body = super()._build_request( + inputs, extra_body=extra_body, streaming=streaming, **parameters + ) + request_body["model"] = self.model.id + if streaming: + request_body["stream"] = True + return request_body + async def _make_request( self, request_body: dict[str, Any], + *, + endpoint: str | None = None, **parameters: Any, - ) -> httpx.Response: + ) -> dict[str, Any]: """Make HTTP request to OpenAI Responses API endpoint.""" - request_body["model"] = self.model.id + if endpoint is None: + endpoint = config.OpenAIResponsesEndpoint.CREATE_RESPONSE headers = { **self.auth.get_headers(), "Content-Type": ApplicationMimeType.JSON, } - return await self.http_client.post( - f"{config.BASE_URL}{config.OpenAIResponsesEndpoint.CREATE_RESPONSE}", + response = await self.http_client.post( + f"{config.BASE_URL}{endpoint}", headers=headers, json_body=request_body, ) + self._handle_error_response(response) + data: dict[str, Any] = response.json() + return data def _make_stream_request( self, request_body: dict[str, Any], + *, + endpoint: str | None = None, **parameters: Any, ) -> AsyncIterator[dict[str, Any]]: """Make streaming request to OpenAI Responses API endpoint.""" - request_body["model"] = self.model.id - request_body["stream"] = True + if endpoint is None: + endpoint = config.OpenAIResponsesEndpoint.CREATE_RESPONSE headers = { **self.auth.get_headers(), @@ -70,13 +92,13 @@ def _make_stream_request( } return self.http_client.stream_post( - f"{config.BASE_URL}{config.OpenAIResponsesEndpoint.CREATE_RESPONSE}", + f"{config.BASE_URL}{endpoint}", headers=headers, json_body=request_body, ) @staticmethod - def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | None]: + def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | float | None]: """Map OpenAI usage fields to unified names. Shared by client and streaming across all capabilities. @@ -91,7 +113,9 @@ def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | None]: UsageField.REASONING_TOKENS: output_details.get("reasoning_tokens"), } - def _parse_usage(self, response_data: dict[str, Any]) -> dict[str, int | None]: + def _parse_usage( + self, response_data: dict[str, Any] + ) -> dict[str, int | float | None]: """Extract usage data from Responses API response.""" usage_data = response_data.get("usage", {}) return OpenAIResponsesClient.map_usage_fields(usage_data) diff --git a/packages/providers/openai/src/celeste_openai/responses/config.py b/src/celeste/providers/openai/responses/config.py similarity index 85% rename from packages/providers/openai/src/celeste_openai/responses/config.py rename to src/celeste/providers/openai/responses/config.py index 0aec0e53..b448d7a6 100644 --- a/packages/providers/openai/src/celeste_openai/responses/config.py +++ b/src/celeste/providers/openai/responses/config.py @@ -4,7 +4,7 @@ class OpenAIResponsesEndpoint(StrEnum): - """Endpoints for Responses API.""" + """Endpoints for OpenAI Responses API.""" CREATE_RESPONSE = "/v1/responses" LIST_MODELS = "/v1/models" diff --git a/packages/providers/openai/src/celeste_openai/responses/parameters.py b/src/celeste/providers/openai/responses/parameters.py similarity index 93% rename from packages/providers/openai/src/celeste_openai/responses/parameters.py rename to src/celeste/providers/openai/responses/parameters.py index 4a33dd14..87fa059d 100644 --- a/packages/providers/openai/src/celeste_openai/responses/parameters.py +++ b/src/celeste/providers/openai/responses/parameters.py @@ -8,7 +8,7 @@ from celeste.models import Model from celeste.parameters import ParameterMapper from celeste.structured_outputs import StrictJsonSchemaGenerator -from celeste.types import StructuredOutput +from celeste.types import TextContent class TemperatureMapper(ParameterMapper): @@ -29,7 +29,7 @@ def map( return request -class MaxTokensMapper(ParameterMapper): +class MaxOutputTokensMapper(ParameterMapper): """Map max_tokens to OpenAI max_output_tokens field.""" def map( @@ -101,8 +101,8 @@ def map( return request -class OutputSchemaMapper(ParameterMapper): - """Map output_schema to OpenAI Structured Outputs format. +class TextFormatMapper(ParameterMapper): + """Map output_schema to OpenAI text.format field. Handles both single BaseModel and list[BaseModel] types. OpenAI requires top-level type: "object", so list types are wrapped. @@ -149,9 +149,7 @@ def map( } return request - def parse_output( - self, content: StructuredOutput, value: object | None - ) -> StructuredOutput: + def parse_output(self, content: TextContent, value: object | None) -> TextContent: """Parse JSON string to BaseModel using Pydantic's TypeAdapter.""" if value is None: return content @@ -176,10 +174,10 @@ def parse_output( __all__ = [ - "MaxTokensMapper", - "OutputSchemaMapper", + "MaxOutputTokensMapper", "ReasoningEffortMapper", "TemperatureMapper", + "TextFormatMapper", "VerbosityMapper", "WebSearchMapper", ] diff --git a/src/celeste/providers/openai/responses/streaming.py b/src/celeste/providers/openai/responses/streaming.py new file mode 100644 index 00000000..3459400c --- /dev/null +++ b/src/celeste/providers/openai/responses/streaming.py @@ -0,0 +1,71 @@ +"""OpenAI Responses SSE parsing for streaming.""" + +from typing import Any + +from celeste.io import FinishReason + +from .client import OpenAIResponsesClient + + +class OpenAIResponsesStream: + """Mixin for Responses API SSE parsing. + + Provides shared implementation for streaming parsing (provider API level): + - _parse_chunk_content(event_data) - Extract content from SSE event + - _parse_chunk_usage(event_data) - Extract and normalize usage from SSE event + - _parse_chunk_finish_reason(event_data) - Extract finish reason from SSE event + + Modality streams call super() methods which resolve to this via MRO. + """ + + def _parse_chunk_content(self, event_data: dict[str, Any]) -> str | None: + """Extract content from SSE event.""" + event_type = event_data.get("type") + + if event_type == "response.output_text.delta": + return event_data.get("delta") + + return None + + def _parse_chunk_usage( + self, event_data: dict[str, Any] + ) -> dict[str, int | float | None] | None: + """Extract and normalize usage from SSE event.""" + event_type = event_data.get("type") + + if event_type == "response.completed": + response_data = event_data.get("response", {}) + usage_data = response_data.get("usage") + if usage_data: + return OpenAIResponsesClient.map_usage_fields(usage_data) + + return None + + def _parse_chunk_finish_reason( + self, event_data: dict[str, Any] + ) -> FinishReason | None: + """Extract finish reason from SSE event.""" + event_type = event_data.get("type") + + if event_type == "response.completed": + response_data = event_data.get("response", {}) + status = response_data.get("status") + if status == "completed": + return FinishReason(reason="completed") + + return None + + def _build_stream_metadata( + self, raw_events: list[dict[str, Any]] + ) -> dict[str, Any]: + """Filter content-only events for size efficiency (content is in Output.content).""" + filtered = [ + e + for e in raw_events + if "delta" not in e.get("type", "") + and e.get("type") != "response.completed" + ] + return super()._build_stream_metadata(filtered) # type: ignore[misc] + + +__all__ = ["OpenAIResponsesStream"] diff --git a/packages/providers/openai/src/celeste_openai/videos/__init__.py b/src/celeste/providers/openai/videos/__init__.py similarity index 100% rename from packages/providers/openai/src/celeste_openai/videos/__init__.py rename to src/celeste/providers/openai/videos/__init__.py diff --git a/packages/providers/openai/src/celeste_openai/videos/client.py b/src/celeste/providers/openai/videos/client.py similarity index 78% rename from packages/providers/openai/src/celeste_openai/videos/client.py rename to src/celeste/providers/openai/videos/client.py index db9a7b58..17f95f50 100644 --- a/packages/providers/openai/src/celeste_openai/videos/client.py +++ b/src/celeste/providers/openai/videos/client.py @@ -6,14 +6,13 @@ import asyncio import base64 -import json import logging +from collections.abc import AsyncIterator from typing import Any -import httpx - from celeste.client import APIMixin from celeste.core import UsageField +from celeste.exceptions import StreamingNotSupportedError from celeste.io import FinishReason from celeste.mime_types import ApplicationMimeType @@ -42,11 +41,39 @@ async def _prepare_multipart_request(self, request_body): # Handle input_reference image uploads... """ + def _build_request( + self, + inputs: Any, + extra_body: dict[str, Any] | None = None, + streaming: bool = False, + **parameters: Any, + ) -> dict[str, Any]: + """Build request with model ID and streaming flag.""" + request_body = super()._build_request( + inputs, extra_body=extra_body, streaming=streaming, **parameters + ) + request_body["model"] = self.model.id + if streaming: + request_body["stream"] = True + return request_body + + def _make_stream_request( + self, + request_body: dict[str, Any], + *, + endpoint: str | None = None, + **parameters: Any, + ) -> AsyncIterator[dict[str, Any]]: + """OpenAI Videos API does not support SSE streaming in this client.""" + raise StreamingNotSupportedError(model_id=self.model.id) + async def _make_request( self, request_body: dict[str, Any], + *, + endpoint: str | None = None, **parameters: Any, - ) -> httpx.Response: + ) -> dict[str, Any]: """Make HTTP request with async polling for OpenAI video generation. Handles the complete async polling workflow: @@ -54,7 +81,8 @@ async def _make_request( 2. Poll for completion 3. Fetch video content """ - request_body["model"] = self.model.id + if endpoint is None: + endpoint = config.OpenAIVideosEndpoint.CREATE_VIDEO headers = { **self.auth.get_headers(), @@ -63,8 +91,6 @@ async def _make_request( files, data = await self._prepare_multipart_request(request_body.copy()) - endpoint = config.OpenAIVideosEndpoint.CREATE_VIDEO - if files: logger.info("Sending multipart request to OpenAI with input_reference") response = await self.http_client.post_multipart( @@ -123,8 +149,8 @@ async def _make_request( self._handle_error_response(content_response) video_data = content_response.content - # Build normalized response - response_data = { + # Return normalized response data + return { "video_data": base64.b64encode(video_data).decode("utf-8"), "model": video_obj.get("model", self.model.id), "video_id": video_id, @@ -135,12 +161,6 @@ async def _make_request( "expires_at": video_obj.get("expires_at"), } - return httpx.Response( - 200, - content=json.dumps(response_data).encode(), - headers={"Content-Type": ApplicationMimeType.JSON}, - ) - async def _prepare_multipart_request( self, request_body: dict[str, Any], @@ -153,7 +173,7 @@ async def _prepare_multipart_request( return {}, {} @staticmethod - def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, Any]: + def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | float | None]: """Map OpenAI Videos usage fields to unified names. Shared by client and streaming across all capabilities. @@ -163,10 +183,23 @@ def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, Any]: UsageField.BILLED_UNITS: usage_data.get("seconds"), } - def _parse_usage(self, response_data: dict[str, Any]) -> dict[str, Any]: + def _parse_usage( + self, response_data: dict[str, Any] + ) -> dict[str, int | float | None]: """Extract usage data from Videos API response.""" return OpenAIVideosClient.map_usage_fields(response_data) + def _parse_content(self, response_data: dict[str, Any]) -> Any: + """Parse video content from response. + + Returns base64-encoded video data that capability clients decode. + """ + video_data = response_data.get("video_data") + if not video_data: + msg = "No video_data in response" + raise ValueError(msg) + return video_data + def _parse_finish_reason(self, response_data: dict[str, Any]) -> FinishReason: """Videos API doesn't provide finish reasons.""" return FinishReason(reason=None) diff --git a/packages/providers/openai/src/celeste_openai/videos/config.py b/src/celeste/providers/openai/videos/config.py similarity index 89% rename from packages/providers/openai/src/celeste_openai/videos/config.py rename to src/celeste/providers/openai/videos/config.py index ea2172a4..479ae997 100644 --- a/packages/providers/openai/src/celeste_openai/videos/config.py +++ b/src/celeste/providers/openai/videos/config.py @@ -4,7 +4,7 @@ class OpenAIVideosEndpoint(StrEnum): - """Endpoints for Videos API.""" + """Endpoints for OpenAI Videos API.""" CREATE_VIDEO = "/v1/videos" diff --git a/packages/providers/openai/src/celeste_openai/videos/parameters.py b/src/celeste/providers/openai/videos/parameters.py similarity index 79% rename from packages/providers/openai/src/celeste_openai/videos/parameters.py rename to src/celeste/providers/openai/videos/parameters.py index a108a62c..eb917280 100644 --- a/packages/providers/openai/src/celeste_openai/videos/parameters.py +++ b/src/celeste/providers/openai/videos/parameters.py @@ -16,15 +16,14 @@ def map( model: Model, ) -> dict[str, Any]: """Transform seconds into provider request.""" - validated_value = self._validate_value(value, model) - if validated_value is None: + if value is None: return request # API expects string, coerce int to string - if isinstance(validated_value, int): - validated_value = str(validated_value) + if isinstance(value, int): + value = str(value) - request["seconds"] = validated_value + request["seconds"] = value return request @@ -38,11 +37,10 @@ def map( model: Model, ) -> dict[str, Any]: """Transform size into provider request.""" - validated_value = self._validate_value(value, model) - if validated_value is None: + if value is None: return request - request["size"] = validated_value + request["size"] = value return request diff --git a/src/celeste/providers/xai/__init__.py b/src/celeste/providers/xai/__init__.py new file mode 100644 index 00000000..71ef0cad --- /dev/null +++ b/src/celeste/providers/xai/__init__.py @@ -0,0 +1,12 @@ +"""XAI provider package for Celeste AI.""" + +from celeste.core import Provider +from celeste.credentials import register_auth + +# Register xAI auth config when package is imported +register_auth( # nosec B106 - env var name, not hardcoded password + provider=Provider.XAI, + secret_name="XAI_API_KEY", + header="Authorization", + prefix="Bearer ", +) diff --git a/src/celeste/providers/xai/py.typed b/src/celeste/providers/xai/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/packages/providers/xai/src/celeste_xai/responses/__init__.py b/src/celeste/providers/xai/responses/__init__.py similarity index 100% rename from packages/providers/xai/src/celeste_xai/responses/__init__.py rename to src/celeste/providers/xai/responses/__init__.py diff --git a/packages/providers/xai/src/celeste_xai/responses/client.py b/src/celeste/providers/xai/responses/client.py similarity index 73% rename from packages/providers/xai/src/celeste_xai/responses/client.py rename to src/celeste/providers/xai/responses/client.py index 254b7c63..51687468 100644 --- a/packages/providers/xai/src/celeste_xai/responses/client.py +++ b/src/celeste/providers/xai/responses/client.py @@ -1,10 +1,8 @@ -"""XAI Responses API client with shared implementation.""" +"""xAI Responses API client mixin.""" from collections.abc import AsyncIterator from typing import Any -import httpx - from celeste.client import APIMixin from celeste.core import UsageField from celeste.io import FinishReason @@ -38,33 +36,57 @@ def _parse_content(self, response_data, **parameters): return "" """ + def _build_request( + self, + inputs: Any, + extra_body: dict[str, Any] | None = None, + streaming: bool = False, + **parameters: Any, + ) -> dict[str, Any]: + """Build request with model ID and streaming flag.""" + request_body = super()._build_request( + inputs, extra_body=extra_body, streaming=streaming, **parameters + ) + request_body["model"] = self.model.id + if streaming: + request_body["stream"] = True + return request_body + async def _make_request( self, request_body: dict[str, Any], + *, + endpoint: str | None = None, **parameters: Any, - ) -> httpx.Response: + ) -> dict[str, Any]: """Make HTTP request to XAI Responses endpoint.""" - request_body["model"] = self.model.id + if endpoint is None: + endpoint = config.XAIResponsesEndpoint.CREATE_RESPONSE headers = { **self.auth.get_headers(), "Content-Type": ApplicationMimeType.JSON, } - return await self.http_client.post( - f"{config.BASE_URL}{config.XAIResponsesEndpoint.CREATE_RESPONSE}", + response = await self.http_client.post( + f"{config.BASE_URL}{endpoint}", headers=headers, json_body=request_body, ) + self._handle_error_response(response) + data: dict[str, Any] = response.json() + return data def _make_stream_request( self, request_body: dict[str, Any], + *, + endpoint: str | None = None, **parameters: Any, ) -> AsyncIterator[dict[str, Any]]: """Make streaming request to XAI Responses endpoint.""" - request_body["model"] = self.model.id - request_body["stream"] = True + if endpoint is None: + endpoint = config.XAIResponsesEndpoint.CREATE_RESPONSE headers = { **self.auth.get_headers(), @@ -72,28 +94,30 @@ def _make_stream_request( } return self.http_client.stream_post( - f"{config.BASE_URL}{config.XAIResponsesEndpoint.CREATE_RESPONSE}", + f"{config.BASE_URL}{endpoint}", headers=headers, json_body=request_body, ) @staticmethod - def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | None]: + def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | float | None]: """Map XAI usage fields to unified names. Shared by client and streaming across all capabilities. """ - input_details = usage_data.get("prompt_tokens_details", {}) - output_details = usage_data.get("completion_tokens_details", {}) + input_details = usage_data.get("input_tokens_details", {}) + output_details = usage_data.get("output_tokens_details", {}) return { - UsageField.INPUT_TOKENS: usage_data.get("prompt_tokens"), - UsageField.OUTPUT_TOKENS: usage_data.get("completion_tokens"), + UsageField.INPUT_TOKENS: usage_data.get("input_tokens"), + UsageField.OUTPUT_TOKENS: usage_data.get("output_tokens"), UsageField.TOTAL_TOKENS: usage_data.get("total_tokens"), UsageField.CACHED_TOKENS: input_details.get("cached_tokens"), UsageField.REASONING_TOKENS: output_details.get("reasoning_tokens"), } - def _parse_usage(self, response_data: dict[str, Any]) -> dict[str, int | None]: + def _parse_usage( + self, response_data: dict[str, Any] + ) -> dict[str, int | float | None]: """Extract usage data from Responses API response.""" usage_data = response_data.get("usage", {}) return XAIResponsesClient.map_usage_fields(usage_data) diff --git a/packages/providers/xai/src/celeste_xai/responses/config.py b/src/celeste/providers/xai/responses/config.py similarity index 85% rename from packages/providers/xai/src/celeste_xai/responses/config.py rename to src/celeste/providers/xai/responses/config.py index d640927d..d38c6ac2 100644 --- a/packages/providers/xai/src/celeste_xai/responses/config.py +++ b/src/celeste/providers/xai/responses/config.py @@ -4,7 +4,7 @@ class XAIResponsesEndpoint(StrEnum): - """Endpoints for Responses API.""" + """Endpoints for XAI Responses API.""" CREATE_RESPONSE = "/v1/responses" LIST_MODELS = "/v1/models" diff --git a/packages/providers/xai/src/celeste_xai/responses/parameters.py b/src/celeste/providers/xai/responses/parameters.py similarity index 95% rename from packages/providers/xai/src/celeste_xai/responses/parameters.py rename to src/celeste/providers/xai/responses/parameters.py index 3b1dce79..bde21739 100644 --- a/packages/providers/xai/src/celeste_xai/responses/parameters.py +++ b/src/celeste/providers/xai/responses/parameters.py @@ -8,7 +8,7 @@ from celeste.models import Model from celeste.parameters import ParameterMapper from celeste.structured_outputs import StrictJsonSchemaGenerator -from celeste.types import StructuredOutput +from celeste.types import TextContent class TemperatureMapper(ParameterMapper): @@ -29,7 +29,7 @@ def map( return request -class MaxTokensMapper(ParameterMapper): +class MaxOutputTokensMapper(ParameterMapper): """Map max_tokens to XAI max_output_tokens field.""" def map( @@ -119,8 +119,8 @@ def map( return request -class OutputSchemaMapper(ParameterMapper): - """Map output_schema to XAI structured outputs format. +class TextFormatMapper(ParameterMapper): + """Map output_schema to XAI text.format field. Handles both single BaseModel and list[BaseModel] types. XAI requires top-level object, so lists are wrapped in {items: []}. @@ -169,9 +169,9 @@ def map( def parse_output( self, - content: StructuredOutput, + content: TextContent, value: object | None, - ) -> StructuredOutput: + ) -> TextContent: """Parse JSON string to BaseModel using Pydantic's TypeAdapter.""" if value is None: return content @@ -197,10 +197,10 @@ def parse_output( __all__ = [ "CodeExecutionMapper", - "MaxTokensMapper", - "OutputSchemaMapper", + "MaxOutputTokensMapper", "ReasoningEffortMapper", "TemperatureMapper", + "TextFormatMapper", "WebSearchMapper", "XSearchMapper", ] diff --git a/src/celeste/providers/xai/responses/streaming.py b/src/celeste/providers/xai/responses/streaming.py new file mode 100644 index 00000000..0108375a --- /dev/null +++ b/src/celeste/providers/xai/responses/streaming.py @@ -0,0 +1,71 @@ +"""XAI Responses SSE parsing for streaming.""" + +from typing import Any + +from celeste.io import FinishReason + +from .client import XAIResponsesClient + + +class XAIResponsesStream: + """Mixin for Responses API SSE parsing. + + Provides shared implementation for streaming parsing (provider API level): + - _parse_chunk_content(event_data) - Extract content from SSE event + - _parse_chunk_usage(event_data) - Extract and normalize usage from SSE event + - _parse_chunk_finish_reason(event_data) - Extract finish reason from SSE event + + Modality streams call super() methods which resolve to this via MRO. + """ + + def _parse_chunk_content(self, event_data: dict[str, Any]) -> str | None: + """Extract content from SSE event.""" + event_type = event_data.get("type") + + if event_type == "response.output_text.delta": + return event_data.get("delta") + + return None + + def _parse_chunk_usage( + self, event_data: dict[str, Any] + ) -> dict[str, int | float | None] | None: + """Extract and normalize usage from SSE event.""" + event_type = event_data.get("type") + + if event_type == "response.completed": + response_data = event_data.get("response", {}) + usage_data = response_data.get("usage") + if usage_data: + return XAIResponsesClient.map_usage_fields(usage_data) + + return None + + def _parse_chunk_finish_reason( + self, event_data: dict[str, Any] + ) -> FinishReason | None: + """Extract finish reason from SSE event.""" + event_type = event_data.get("type") + + if event_type == "response.completed": + response_data = event_data.get("response", {}) + status = response_data.get("status") + if status == "completed": + return FinishReason(reason="completed") + + return None + + def _build_stream_metadata( + self, raw_events: list[dict[str, Any]] + ) -> dict[str, Any]: + """Filter content-only events for size efficiency (content is in Output.content).""" + filtered = [ + e + for e in raw_events + if "delta" not in e.get("type", "") + and e.get("type") != "response.completed" + ] + return super()._build_stream_metadata(filtered) # type: ignore[misc] + + +__all__ = ["XAIResponsesStream"] diff --git a/src/celeste/registry.py b/src/celeste/registry.py deleted file mode 100644 index 514b51b9..00000000 --- a/src/celeste/registry.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Package registry for lazy loading entry points.""" - -import importlib.metadata - -_loaded_packages: set[str] = set() -_loaded_providers: set[str] = set() - - -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") - - # Early return if all packages are already loaded - entry_point_names = {ep.name for ep in entry_points} - if entry_point_names.issubset(_loaded_packages): - return - - for ep in entry_points: - if ep.name in _loaded_packages: - continue - register_func = ep.load() - # The function should register models and clients when called - register_func() - _loaded_packages.add(ep.name) - - -def _load_providers_from_entry_points() -> None: - """Load auth from installed provider packages via entry points.""" - - entry_points = importlib.metadata.entry_points(group="celeste.providers") - - # Early return if all providers are already loaded - entry_point_names = {ep.name for ep in entry_points} - if entry_point_names.issubset(_loaded_providers): - return - - for ep in entry_points: - if ep.name in _loaded_providers: - continue - register_func = ep.load() - # The function should register auth types when called - register_func() - _loaded_providers.add(ep.name) diff --git a/src/celeste/streaming.py b/src/celeste/streaming.py index 36de0a44..6e6d9bcb 100644 --- a/src/celeste/streaming.py +++ b/src/celeste/streaming.py @@ -2,17 +2,28 @@ from abc import ABC, abstractmethod from collections.abc import AsyncIterator +from contextlib import AbstractContextManager, suppress from types import TracebackType from typing import Any, Self, Unpack -from celeste.exceptions import StreamEmptyError, StreamNotExhaustedError +from anyio.from_thread import BlockingPortal, start_blocking_portal + +from celeste.exceptions import StreamNotExhaustedError from celeste.io import Chunk as ChunkBase from celeste.io import Output from celeste.parameters import Parameters class Stream[Out: Output, Params: Parameters, Chunk: ChunkBase](ABC): - """Async iterator wrapper providing final Output access after stream exhaustion.""" + """Async iterator wrapper providing final Output access after stream exhaustion. + + Supports both async iteration (`async for chunk in stream`) and sync iteration + (`for chunk in stream`). Sync iteration uses anyio's blocking portal to maintain + a persistent event loop in a dedicated thread. + + Note: For high-volume scenarios, async iteration is recommended. Sync iteration + creates a background thread per stream. + """ def __init__( self, @@ -25,6 +36,9 @@ def __init__( self._closed = False self._output: Out | None = None self._parameters = parameters + # Sync iteration state + self._portal: BlockingPortal | None = None + self._portal_cm: AbstractContextManager[BlockingPortal] | None = None @abstractmethod def _parse_chunk(self, event: dict[str, Any]) -> Chunk | None: @@ -36,6 +50,12 @@ def _parse_output(self, chunks: list[Chunk], **parameters: Unpack[Params]) -> Ou """Parse final Output from accumulated chunks.""" ... + def _build_stream_metadata( + self, raw_events: list[dict[str, Any]] + ) -> dict[str, Any]: + """Build metadata for streaming. Providers override to filter content.""" + return {"raw_events": raw_events} + def __repr__(self) -> str: """Developer-friendly representation showing stream state.""" if self._output: @@ -67,19 +87,50 @@ async def __anext__(self) -> Chunk: self._chunks.append(chunk) return chunk - # Stream exhausted - validate and parse final output - if not self._chunks: - raise StreamEmptyError() - - self._output = self._parse_output(self._chunks, **self._parameters) + # Stream exhausted naturally + if self._chunks: + self._output = self._parse_output(self._chunks, **self._parameters) + self._closed = True except Exception: await self.aclose() raise - # Only reached on successful exhaustion - await self.aclose() raise StopAsyncIteration + # Iterator protocol (sync) + def __iter__(self) -> Self: + """Return self as sync iterator with dedicated event loop. + + Creates a blocking portal that maintains a persistent event loop + in a dedicated thread for consistent async context. + """ + if self._portal is None: + self._portal_cm = start_blocking_portal() + self._portal = self._portal_cm.__enter__() + return self + + def __next__(self) -> Chunk: + """Yield next chunk via portal's persistent event loop.""" + if self._portal is None: + self.__iter__() + + try: + return self._portal.call(self.__anext__) # type: ignore[union-attr,no-any-return] + except StopAsyncIteration: + self._cleanup_portal() + raise StopIteration from None + + def _cleanup_portal(self) -> None: + """Clean up the blocking portal and its thread.""" + if self._portal_cm is not None: + # Close stream via portal before exiting (ensures _closed = True) + if self._portal is not None and not self._closed: + with suppress(RuntimeError): + self._portal.call(self.aclose) + self._portal_cm.__exit__(None, None, None) + self._portal = None + self._portal_cm = None + # AsyncContextManager protocol async def __aenter__(self) -> Self: """Enter context - return self for iteration.""" @@ -95,6 +146,20 @@ async def __aexit__( await self.aclose() return False # Propagate exceptions + # ContextManager protocol (sync) + def __enter__(self) -> Self: + """Enter sync context - return self for iteration.""" + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """Exit sync context - ensure cleanup.""" + self._cleanup_portal() + @property def output(self) -> Out: """Access final Output after stream exhaustion (raises StreamNotExhaustedError if not ready).""" @@ -109,9 +174,15 @@ async def aclose(self) -> None: self._closed = True + # Fast path: skip if iterator is currently running + if getattr(self._sse_iterator, "ag_running", False): + return + # Close SSE iterator (httpx-sse connection) + # Use suppress to handle TOCTOU race between ag_running check and aclose if hasattr(self._sse_iterator, "aclose"): - await self._sse_iterator.aclose() + with suppress(RuntimeError): + await self._sse_iterator.aclose() __all__ = ["Stream"] diff --git a/src/celeste/types.py b/src/celeste/types.py index ab316763..68d443bf 100644 --- a/src/celeste/types.py +++ b/src/celeste/types.py @@ -2,10 +2,23 @@ from pydantic import BaseModel +from celeste.artifacts import AudioArtifact, ImageArtifact, VideoArtifact + type JsonValue = ( str | int | float | bool | None | dict[str, JsonValue] | list[JsonValue] ) -type StructuredOutput = str | JsonValue | BaseModel | list[BaseModel] +type TextContent = str | JsonValue | BaseModel | list[BaseModel] +type AudioContent = AudioArtifact | list[AudioArtifact] +type ImageContent = ImageArtifact | list[ImageArtifact] +type VideoContent = VideoArtifact | list[VideoArtifact] +type EmbeddingsContent = list[float] | list[list[float]] -__all__ = ["JsonValue", "StructuredOutput"] +__all__ = [ + "AudioContent", + "EmbeddingsContent", + "ImageContent", + "JsonValue", + "TextContent", + "VideoContent", +] diff --git a/src/celeste/utils.py b/src/celeste/utils.py deleted file mode 100644 index 13eba3dd..00000000 --- a/src/celeste/utils.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Utility functions for Celeste.""" - -import base64 - -from celeste.artifacts import ImageArtifact - - -def image_to_data_uri(image: ImageArtifact) -> str: - """Convert an ImageArtifact to a base64 data URI string. - - Args: - image: ImageArtifact with data or path. - - Returns: - Data URI string (e.g., "data:image/png;base64,iVBORw0KGgo..."). - - Raises: - ValueError: If image has neither data nor path. - """ - if image.data: - file_data = image.data - elif image.path: - with open(image.path, "rb") as f: - file_data = f.read() - else: - msg = "ImageArtifact must have data or path" - raise ValueError(msg) - - base64_data = base64.b64encode(file_data).decode("utf-8") - mime_type = image.mime_type.value if image.mime_type else "image/jpeg" - - return f"data:{mime_type};base64,{base64_data}" - - -__all__ = ["image_to_data_uri"] diff --git a/src/celeste/utils/__init__.py b/src/celeste/utils/__init__.py new file mode 100644 index 00000000..b67aa1df --- /dev/null +++ b/src/celeste/utils/__init__.py @@ -0,0 +1,15 @@ +"""Celeste utility functions.""" + +from celeste.utils.image import get_image_dimensions +from celeste.utils.mime import ( + build_image_data_url, + detect_mime_type, + detect_mime_type_from_path, +) + +__all__ = [ + "build_image_data_url", + "detect_mime_type", + "detect_mime_type_from_path", + "get_image_dimensions", +] diff --git a/src/celeste/utils/image.py b/src/celeste/utils/image.py new file mode 100644 index 00000000..a8a5d65e --- /dev/null +++ b/src/celeste/utils/image.py @@ -0,0 +1,126 @@ +"""Minimal image dimension reader - pure Python, no dependencies. + +Supports: PNG, JPEG, WebP (VP8/VP8L/VP8X), GIF. +Returns (width, height) or None if format unrecognized. +""" + +import struct +from io import BytesIO + + +def get_image_dimensions(data: bytes) -> tuple[int, int] | None: + """Get (width, height) from image bytes. + + Supports PNG, JPEG, WebP, GIF. + Returns None if format is unrecognized or dimensions cannot be parsed. + """ + if len(data) < 24: + return None + + if data.startswith(b"\x89PNG\r\n\x1a\n"): + return _get_png_dimensions(data) + + if data.startswith((b"GIF87a", b"GIF89a")): + return _get_gif_dimensions(data) + + if data.startswith(b"RIFF") and data[8:12] == b"WEBP": + return _get_webp_dimensions(data) + + if data.startswith(b"\xff\xd8"): + return _get_jpeg_dimensions(data) + + return None + + +def _get_png_dimensions(data: bytes) -> tuple[int, int] | None: + """Extract dimensions from PNG header (IHDR chunk).""" + if data[12:16] != b"IHDR": + return None + try: + w, h = struct.unpack(">II", data[16:24]) + return w, h + except struct.error: + return None + + +def _get_gif_dimensions(data: bytes) -> tuple[int, int] | None: + """Extract dimensions from GIF header.""" + try: + w, h = struct.unpack(" tuple[int, int] | None: + """Extract dimensions from WebP header (VP8/VP8L/VP8X chunks).""" + chunk_type = data[12:16] + + try: + if chunk_type == b"VP8 ": # Lossy + if len(data) < 30: + return None + # RFC 6386: 14 bits width, 2 bits scale + w, h = struct.unpack("> 6)) + return w, h + + except struct.error: + return None + + return None + + +def _get_jpeg_dimensions(data: bytes) -> tuple[int, int] | None: + """Extract dimensions from JPEG header (SOF marker).""" + try: + stream = BytesIO(data) + stream.seek(2) + + while True: + # Read marker + b = stream.read(2) + if len(b) < 2: + break + (marker,) = struct.unpack(">H", b) + + # SOS (Start of Scan) or EOI (End of Image) -> stop + if marker == 0xFFDA or marker == 0xFFD9: + break + + # Read chunk length + b_len = stream.read(2) + if len(b_len) < 2: + break + (size,) = struct.unpack(">H", b_len) + + # SOF markers (FFC0..FFCF) except DHT/JPG/DAC + # SOF0=FFC0 (Baseline), SOF2=FFC2 (Progressive) are the common ones + if 0xFFC0 <= marker <= 0xFFCF and marker not in (0xFFC4, 0xFFC8, 0xFFCC): + stream.read(1) # precision + h, w = struct.unpack(">HH", stream.read(4)) + return w, h + + # Skip segment + stream.seek(size - 2, 1) + + except (struct.error, ValueError): + pass + + return None diff --git a/src/celeste/utils/mime.py b/src/celeste/utils/mime.py new file mode 100644 index 00000000..c58e4c3f --- /dev/null +++ b/src/celeste/utils/mime.py @@ -0,0 +1,85 @@ +"""MIME type detection utilities.""" + +import filetype + +from celeste.artifacts import ImageArtifact +from celeste.mime_types import AudioMimeType, ImageMimeType, MimeType, VideoMimeType + + +def detect_mime_type(data: bytes) -> MimeType | None: + """Detect MIME type from magic bytes. + + Uses the filetype library to detect file type from the first 261 bytes + of binary data (magic number signatures). + + Args: + data: Binary data to analyze. + + Returns: + Detected MimeType or None if unknown. + + Example: + >>> with open("image.png", "rb") as f: + ... mime = detect_mime_type(f.read()) + >>> print(mime) # ImageMimeType.PNG + """ + result = filetype.guess(data) + if result is None: + return None + + mime_str = result.mime + + # Try to match against our known MIME type enums + for mime_enum in (ImageMimeType, VideoMimeType, AudioMimeType): + try: + return mime_enum(mime_str) + except ValueError: + continue + + return None + + +def detect_mime_type_from_path(path: str) -> MimeType | None: + """Detect MIME type from a file path. + + Reads the file header and detects type from magic bytes. + + Args: + path: Path to the file. + + Returns: + Detected MimeType or None if unknown. + """ + result = filetype.guess(path) + if result is None: + return None + + mime_str = result.mime + + for mime_enum in (ImageMimeType, VideoMimeType, AudioMimeType): + try: + return mime_enum(mime_str) + except ValueError: + continue + + return None + + +def build_image_data_url(img: ImageArtifact) -> str: + """Build a data URL from an ImageArtifact. + + For images with only a URL (no data or path), returns the URL directly. + For images with data or path, builds a data URL with MIME type detection. + """ + + if img.url and not img.data and not img.path: + return img.url + + image_bytes = img.get_bytes() + mime = img.mime_type or detect_mime_type(image_bytes) + mime_str = mime.value if mime else "" + + return f"data:{mime_str};base64,{img.get_base64()}" + + +__all__ = ["build_image_data_url", "detect_mime_type", "detect_mime_type_from_path"] diff --git a/src/celeste/websocket.py b/src/celeste/websocket.py index c5adabf4..071a3ab5 100644 --- a/src/celeste/websocket.py +++ b/src/celeste/websocket.py @@ -8,7 +8,7 @@ from websockets.asyncio.client import ClientConnection from websockets.asyncio.client import connect as ws_connect -from celeste.core import Capability, Provider +from celeste.core import Modality, Provider logger = logging.getLogger(__name__) @@ -95,20 +95,20 @@ async def __aexit__(self, *args: object) -> None: # Module-level registry (mirrors http.py pattern) -_ws_clients: dict[tuple[Provider, Capability], WebSocketClient] = {} +_ws_clients: dict[tuple[Provider, Modality], WebSocketClient] = {} -def get_ws_client(provider: Provider, capability: Capability) -> WebSocketClient: - """Get or create shared WebSocket client for provider/capability. +def get_ws_client(provider: Provider, modality: Modality) -> WebSocketClient: + """Get or create shared WebSocket client for provider/modality. Args: provider: The AI provider. - capability: The capability being used. + modality: The modality being used. Returns: - Shared WebSocketClient instance for this provider and capability. + Shared WebSocketClient instance for this provider and modality. """ - key = (provider, capability) + key = (provider, modality) if key not in _ws_clients: _ws_clients[key] = WebSocketClient() return _ws_clients[key] diff --git a/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/__init__.py.template b/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/__init__.py.template new file mode 100644 index 00000000..5cad59f0 --- /dev/null +++ b/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/__init__.py.template @@ -0,0 +1,25 @@ +"""Celeste {Modality} modality.""" + +from .client import {Modality}Client, {Modality}StreamNamespace +from .io import ( + {Modality}Chunk, + {Modality}FinishReason, + {Modality}Input, + {Modality}Output, + {Modality}Usage, +) +from .parameters import {Modality}Parameter, {Modality}Parameters +from .streaming import {Modality}Stream + +__all__ = [ + "{Modality}Chunk", + "{Modality}Client", + "{Modality}FinishReason", + "{Modality}Input", + "{Modality}Output", + "{Modality}Parameter", + "{Modality}Parameters", + "{Modality}Stream", + "{Modality}StreamNamespace", + "{Modality}Usage", +] diff --git a/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/client.py.template b/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/client.py.template new file mode 100644 index 00000000..150d30e0 --- /dev/null +++ b/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/client.py.template @@ -0,0 +1,214 @@ +"""{Modality} modality client.""" + +from typing import Unpack + +from asgiref.sync import async_to_sync + +from celeste.client import ModalityClient +from celeste.core import InputType, Modality +from celeste.types import AudioContent, ImageContent, {Content}, VideoContent + +from .io import {Modality}Input, {Modality}Output +from .parameters import {Modality}Parameters +from .streaming import {Modality}Stream + + +class {Modality}Client(ModalityClient[{Modality}Input, {Modality}Output, {Modality}Parameters, {Content}]): + """Base {modality} client. + + Providers implement operation methods (generate, analyze). + """ + + modality: Modality = Modality.{MODALITY} + + @classmethod + def _output_class(cls) -> type[{Modality}Output]: + """Return the Output class for {modality} modality.""" + return {Modality}Output + + def _check_media_support( + self, + image: ImageContent | None, + video: VideoContent | None, + audio: AudioContent | None, + ) -> None: + """Check model supports the provided media types. + + Raises: + NotImplementedError: If media type is provided but model doesn't support it. + """ + if image is not None and InputType.IMAGE not in self.model.optional_input_types: + msg = f"Model {self.model.id} does not support image input" + raise NotImplementedError(msg) + if video is not None and InputType.VIDEO not in self.model.optional_input_types: + msg = f"Model {self.model.id} does not support video input" + raise NotImplementedError(msg) + if audio is not None and InputType.AUDIO not in self.model.optional_input_types: + msg = f"Model {self.model.id} does not support audio input" + raise NotImplementedError(msg) + + @property + def stream(self) -> "{Modality}StreamNamespace": + """Streaming namespace for {modality} operations.""" + return {Modality}StreamNamespace(self) + + @property + def sync(self) -> "{Modality}SyncNamespace": + """Sync namespace for {modality} operations.""" + return {Modality}SyncNamespace(self) + + +class {Modality}StreamNamespace: + """Streaming namespace for {modality} operations. + + Provides `client.stream.generate()` and `client.stream.analyze()`. + """ + + def __init__(self, client: {Modality}Client) -> None: + self._client = client + + def generate( + self, + prompt: str, + **parameters: Unpack[{Modality}Parameters], + ) -> {Modality}Stream: + """Stream {modality} generation. + + Usage: + async for chunk in client.stream.generate("Hello"): + print(chunk.content) + """ + inputs = {Modality}Input(prompt=prompt) + return self._client._stream( + inputs, + stream_class=self._client._stream_class(), + **parameters, + ) + + def analyze( + self, + prompt: str, + *, + image: ImageContent | None = None, + video: VideoContent | None = None, + audio: AudioContent | None = None, + **parameters: Unpack[{Modality}Parameters], + ) -> {Modality}Stream: + """Stream media analysis (image, video, or audio). + + Usage: + async for chunk in client.stream.analyze("Describe", image=img): + print(chunk.content) + """ + self._client._check_media_support(image=image, video=video, audio=audio) + inputs = {Modality}Input(prompt=prompt, image=image, video=video, audio=audio) + return self._client._stream( + inputs, + stream_class=self._client._stream_class(), + **parameters, + ) + + +class {Modality}SyncNamespace: + """Sync namespace for {modality} operations. + + Provides `client.sync.generate()` and `client.sync.analyze()`. + """ + + def __init__(self, client: {Modality}Client) -> None: + self._client = client + + def generate( + self, + prompt: str, + **parameters: Unpack[{Modality}Parameters], + ) -> {Modality}Output: + """Blocking {modality} generation. + + Usage: + result = client.sync.generate("Hello") + print(result.content) + """ + inputs = {Modality}Input(prompt=prompt) + return async_to_sync(self._client._predict)(inputs, **parameters) + + def analyze( + self, + prompt: str, + *, + image: ImageContent | None = None, + video: VideoContent | None = None, + audio: AudioContent | None = None, + **parameters: Unpack[{Modality}Parameters], + ) -> {Modality}Output: + """Blocking media analysis (image, video, or audio). + + Usage: + result = client.sync.analyze("Describe", image=img) + print(result.content) + """ + self._client._check_media_support(image=image, video=video, audio=audio) + inputs = {Modality}Input(prompt=prompt, image=image, video=video, audio=audio) + return async_to_sync(self._client._predict)(inputs, **parameters) + + @property + def stream(self) -> "{Modality}SyncStreamNamespace": + """Sync streaming namespace.""" + return {Modality}SyncStreamNamespace(self._client) + + +class {Modality}SyncStreamNamespace: + """Sync streaming namespace - returns Stream instance with sync iteration support.""" + + def __init__(self, client: {Modality}Client) -> None: + self._client = client + + def generate( + self, + prompt: str, + **parameters: Unpack[{Modality}Parameters], + ) -> {Modality}Stream: + """Sync streaming {modality} generation. + + Returns Stream instance that supports both async and sync iteration. + + Usage: + stream = client.sync.stream.generate("Hello") + for chunk in stream: # Sync iteration (bridges async internally) + print(chunk.content, end="") + print(stream.output.usage) + """ + # Return same stream as async version - __iter__/__next__ handle sync iteration + return self._client.stream.generate(prompt, **parameters) + + def analyze( + self, + prompt: str, + *, + image: ImageContent | None = None, + video: VideoContent | None = None, + audio: AudioContent | None = None, + **parameters: Unpack[{Modality}Parameters], + ) -> {Modality}Stream: + """Sync streaming media analysis (image, video, or audio). + + Returns Stream instance that supports both async and sync iteration. + + Usage: + stream = client.sync.stream.analyze("Describe", image=img) + for chunk in stream: # Sync iteration (bridges async internally) + print(chunk.content, end="") + print(stream.output.usage) + """ + # Return same stream as async version - __iter__/__next__ handle sync iteration + return self._client.stream.analyze( + prompt, image=image, video=video, audio=audio, **parameters + ) + + +__all__ = [ + "{Modality}Client", + "{Modality}StreamNamespace", + "{Modality}SyncNamespace", + "{Modality}SyncStreamNamespace", +] diff --git a/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/io.py.template b/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/io.py.template new file mode 100644 index 00000000..015da8da --- /dev/null +++ b/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/io.py.template @@ -0,0 +1,62 @@ +"""IO types for {modality} modality. + +{Modality} modality handles: +- {Modality} generation (prompt -> {content}) +- Analysis of any media type (text/image/video/audio + prompt -> {content}) + +Types are unified per-modality since generate and analyze produce identical outputs. +""" + +from pydantic import Field + +from celeste.io import Chunk, FinishReason, Input, Output, Usage +from celeste.types import AudioContent, ImageContent, {Content}, VideoContent + + +class {Modality}Input(Input): + """Input for {modality} operations.""" + + prompt: str + text: str | list[str] | None = None + image: ImageContent | None = None + video: VideoContent | None = None + audio: AudioContent | None = None + + +class {Modality}FinishReason(FinishReason): + """{Modality} finish reason.""" + + reason: str | None = None + message: str | None = None + + +class {Modality}Usage(Usage): + """{Modality} usage metrics.""" + + total_tokens: int | None = None + input_tokens: int | None = None + output_tokens: int | None = None + reasoning_tokens: int | None = None + + +class {Modality}Output(Output[{Content}]): + """Output from {modality} operations.""" + + usage: {Modality}Usage = Field(default_factory={Modality}Usage) + finish_reason: {Modality}FinishReason | None = None + + +class {Modality}Chunk(Chunk[{Content}]): + """Chunk for {modality} streaming.""" + + finish_reason: {Modality}FinishReason | None = None + usage: {Modality}Usage | None = None + + +__all__ = [ + "{Modality}Chunk", + "{Modality}FinishReason", + "{Modality}Input", + "{Modality}Output", + "{Modality}Usage", +] diff --git a/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/parameters.py.template b/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/parameters.py.template new file mode 100644 index 00000000..2e8a73c1 --- /dev/null +++ b/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/parameters.py.template @@ -0,0 +1,54 @@ +"""Parameters for {modality} modality. + +Unified parameters for all {modality} operations (generate, analyze). +Model `parameter_constraints` enforces which parameters are valid per model. +""" + +from enum import StrEnum + +from pydantic import BaseModel + +from celeste.parameters import Parameters + + +class {Modality}Parameter(StrEnum): + """Unified parameter names for {modality} modality.""" + + # Common parameters + TEMPERATURE = "temperature" + MAX_TOKENS = "max_tokens" + SEED = "seed" + + # {Modality}-specific parameters (customize as needed) + # THINKING_BUDGET = "thinking_budget" + # THINKING_LEVEL = "thinking_level" + # OUTPUT_SCHEMA = "output_schema" + # WEB_SEARCH = "web_search" + # VERBOSITY = "verbosity" + + # Media input declarations (for optional_input_types) + # IMAGE = "image" + # VIDEO = "video" + # AUDIO = "audio" + + +class {Modality}Parameters(Parameters): + """Parameters for {modality} operations.""" + + # Common parameters + temperature: float + max_tokens: int + seed: int + + # {Modality}-specific parameters (customize as needed) + # thinking_budget: int | str + # thinking_level: str + # output_schema: type[BaseModel] + # web_search: bool + # verbosity: str + + +__all__ = [ + "{Modality}Parameter", + "{Modality}Parameters", +] diff --git a/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/providers/__init__.py.template b/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/providers/__init__.py.template new file mode 100644 index 00000000..8f423a78 --- /dev/null +++ b/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/providers/__init__.py.template @@ -0,0 +1,18 @@ +"""{Modality} providers. + +Placeholder Reference: +- {Modality} -> PascalCase modality name (e.g., Images, Text, Speech) +- {modality} -> lowercase modality name (e.g., images, text, speech) +""" + +from celeste.core import Provider + +from ..client import {Modality}Client +from .openai import OpenAI{Modality}Client + +# from .google import Google{Modality}Client + +PROVIDERS: dict[Provider, type[{Modality}Client]] = { + Provider.OPENAI: OpenAI{Modality}Client, + # Provider.GOOGLE: Google{Modality}Client, +} diff --git a/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/providers/{provider_slug}/__init__.py.template b/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/providers/{provider_slug}/__init__.py.template new file mode 100644 index 00000000..a20de61b --- /dev/null +++ b/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/providers/{provider_slug}/__init__.py.template @@ -0,0 +1,6 @@ +"""{Provider} provider for {modality} modality.""" + +from .client import {Provider}{Modality}Client +from .models import MODELS + +__all__ = ["MODELS", "{Provider}{Modality}Client"] diff --git a/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/providers/{provider_slug}/client.py.template b/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/providers/{provider_slug}/client.py.template new file mode 100644 index 00000000..da143fdf --- /dev/null +++ b/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/providers/{provider_slug}/client.py.template @@ -0,0 +1,141 @@ +"""{Provider} {modality} client.""" + +from typing import Any, Unpack + +from celeste.artifacts import {Artifact}Artifact +from celeste.parameters import ParameterMapper +from celeste.providers.{provider}.{api} import config +from celeste.providers.{provider}.{api}.client import {Provider}{Api}Client as {Provider}{Api}Mixin +from celeste.providers.{provider}.{api}.streaming import {Provider}{Api}Stream as _{Provider}{Api}Stream +from celeste.types import {Content} + +from ...client import {Modality}Client +from ...io import ( + {Modality}Chunk, + {Modality}FinishReason, + {Modality}Input, + {Modality}Output, + {Modality}Usage, +) +from ...parameters import {Modality}Parameters +from ...streaming import {Modality}Stream +from .parameters import {PROVIDER}_PARAMETER_MAPPERS + + +class {Provider}{Modality}Stream(_{Provider}{Api}Stream, {Modality}Stream): + """{Provider} streaming for {modality} modality.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401 + super().__init__(*args, **kwargs) + self._message_start: dict[str, Any] | None = None + + def _parse_chunk_usage( + self, event_data: dict[str, Any] + ) -> {Modality}Usage | None: + """Parse and wrap usage from SSE event.""" + usage = super()._parse_chunk_usage(event_data) + if usage: + return {Modality}Usage(**usage) + return None + + def _parse_chunk_finish_reason( + self, event_data: dict[str, Any] + ) -> {Modality}FinishReason | None: + """Parse and wrap finish reason from SSE event.""" + finish_reason = super()._parse_chunk_finish_reason(event_data) + if finish_reason: + return {Modality}FinishReason(reason=finish_reason.reason) + return None + + def _parse_chunk(self, event_data: dict[str, Any]) -> {Modality}Chunk | None: + """Parse one SSE event into a typed chunk.""" + # TODO: Handle provider-specific start events (e.g., message_start) + # if event_data.get("type") == "message_start": + # self._message_start = event_data.get("message") + # return None + + content = self._parse_chunk_content(event_data) + if content is None: + usage = self._parse_chunk_usage(event_data) + finish_reason = self._parse_chunk_finish_reason(event_data) + if usage is None and finish_reason is None: + return None + content = "" # Or appropriate default for modality + + return {Modality}Chunk( + content=content, + finish_reason=self._parse_chunk_finish_reason(event_data), + usage=self._parse_chunk_usage(event_data), + metadata={"event_data": event_data}, + ) + + def _aggregate_content(self, chunks: list[{Modality}Chunk]) -> Any: + """Aggregate streamed content (modality-specific). + + Examples: + - Text: "".join(chunk.content for chunk in chunks) + - Images: chunks[-1].content + """ + # TODO: Implement modality-specific content aggregation + msg = "Implement content aggregation" + raise NotImplementedError(msg) + + def _aggregate_event_data(self, chunks: list[{Modality}Chunk]) -> list[dict[str, Any]]: + """Collect raw events (filtering happens in _build_stream_metadata).""" + events: list[dict[str, Any]] = [] + if self._message_start is not None: + events.append({"type": "message_start", "message": self._message_start}) + for chunk in chunks: + event_data = chunk.metadata.get("event_data") + if isinstance(event_data, dict): + events.append(event_data) + return events + + +class {Provider}{Modality}Client({Provider}{Api}Mixin, {Modality}Client): + """{Provider} {modality} client.""" + + @classmethod + def parameter_mappers(cls) -> list[ParameterMapper]: + return {PROVIDER}_PARAMETER_MAPPERS + + def _stream_class(self) -> type[{Modality}Stream]: + """Return the Stream class for this provider.""" + return {Provider}{Modality}Stream + + async def generate( + self, + prompt: str, + **parameters: Unpack[{Modality}Parameters], + ) -> {Modality}Output: + """Generate {modality} from prompt.""" + inputs = {Modality}Input(prompt=prompt) + return await self._predict( + inputs, + endpoint=config.{Provider}{Api}Endpoint.{ENDPOINT}, + **parameters, + ) + + def _parse_usage(self, response_data: dict[str, Any]) -> {Modality}Usage: + """Parse usage from response.""" + usage = super()._parse_usage(response_data) + return {Modality}Usage(**usage) + + def _parse_content( + self, + response_data: dict[str, Any], + **parameters: Unpack[{Modality}Parameters], + ) -> {Content}: + """Parse content from response.""" + data = super()._parse_content(response_data) + # TODO: Implement provider-specific content extraction + msg = "Implement content extraction" + raise NotImplementedError(msg) + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> {Modality}FinishReason: + """Parse finish reason from response.""" + finish_reason = super()._parse_finish_reason(response_data) + return {Modality}FinishReason(reason=finish_reason.reason) + + +__all__ = ["{Provider}{Modality}Client", "{Provider}{Modality}Stream"] diff --git a/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/providers/{provider_slug}/models.py.template b/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/providers/{provider_slug}/models.py.template new file mode 100644 index 00000000..87e237a8 --- /dev/null +++ b/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/providers/{provider_slug}/models.py.template @@ -0,0 +1,33 @@ +"""{Provider} models for {modality} modality.""" + +from celeste import Model, Provider +from celeste.constraints import Choice, Range # Add others as needed: Bool, Dimensions, Int, Pattern, Schema +from celeste.core import Modality, Operation + +from ...parameters import {Modality}Parameter + +MODELS: list[Model] = [ + Model( + id="{model_id}", + provider=Provider.{PROVIDER}, + display_name="{Model Display Name}", + operations={Modality.{MODALITY}: {Operation.GENERATE}}, + parameter_constraints={ + {Modality}Parameter.ASPECT_RATIO: Choice( + options=["1024x1024", "1792x1024", "1024x1792"] + ), + # {Modality}Parameter.QUALITY: Choice(options=["standard", "hd"]), + # {Modality}Parameter.NUM_IMAGES: Range(min=1, max=4), + }, + ), + # Model( + # id="{another_model_id}", + # provider=Provider.{PROVIDER}, + # display_name="{Another Model}", + # operations={Modality.{MODALITY}: {Operation.GENERATE, Operation.EDIT}}, + # streaming=True, + # parameter_constraints={ + # {Modality}Parameter.ASPECT_RATIO: Choice(options=["1:1", "16:9"]), + # }, + # ), +] diff --git a/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/providers/{provider_slug}/parameters.py.template b/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/providers/{provider_slug}/parameters.py.template new file mode 100644 index 00000000..800756e3 --- /dev/null +++ b/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/providers/{provider_slug}/parameters.py.template @@ -0,0 +1,21 @@ +"""{Provider} parameter mappers for {modality}.""" + +from celeste.parameters import ParameterMapper +from celeste.providers.{provider}.{api}.parameters import ( + ExampleMapper as _ExampleMapper, +) + +from ...parameters import {Modality}Parameter + + +class AspectRatioMapper(_ExampleMapper): + """Map aspect_ratio to {Provider}'s parameter.""" + + name = {Modality}Parameter.ASPECT_RATIO + + +{PROVIDER}_PARAMETER_MAPPERS: list[ParameterMapper] = [ + AspectRatioMapper(), +] + +__all__ = ["{PROVIDER}_PARAMETER_MAPPERS"] diff --git a/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/streaming.py.template b/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/streaming.py.template new file mode 100644 index 00000000..999cd740 --- /dev/null +++ b/templates/modalities/{modality_slug}/src/celeste_{modality_slug}/streaming.py.template @@ -0,0 +1,88 @@ +"""{Modality} streaming primitives.""" + +from abc import abstractmethod +from collections.abc import AsyncIterator, Callable +from typing import Any, Unpack + +from celeste.client import ModalityClient +from celeste.streaming import Stream + +from .io import ( + {Modality}Chunk, + {Modality}FinishReason, + {Modality}Output, + {Modality}Usage, +) +from .parameters import {Modality}Parameters + + +class {Modality}Stream(Stream[{Modality}Output, {Modality}Parameters, {Modality}Chunk]): + """Streaming for {modality} modality.""" + + def __init__( + self, + sse_iterator: AsyncIterator[dict[str, Any]], + transform_output: Callable[..., {Content}], + client: ModalityClient, + **parameters: Unpack[{Modality}Parameters], + ) -> None: + super().__init__(sse_iterator, **parameters) + self._transform_output = transform_output + self._client = client + + @abstractmethod + def _aggregate_content(self, chunks: list[{Modality}Chunk]) -> Any: + """Aggregate content from chunks into raw content (modality-specific).""" + ... + + def _aggregate_usage(self, chunks: list[{Modality}Chunk]) -> {Modality}Usage: + """Aggregate usage across chunks (universal).""" + for chunk in reversed(chunks): + if chunk.usage: + return chunk.usage + return {Modality}Usage() + + def _aggregate_finish_reason( + self, + chunks: list[{Modality}Chunk], + ) -> {Modality}FinishReason | None: + """Aggregate finish reason across chunks (universal).""" + for chunk in reversed(chunks): + if chunk.finish_reason: + return chunk.finish_reason + return None + + @abstractmethod + def _aggregate_event_data(self, chunks: list[{Modality}Chunk]) -> list[dict[str, Any]]: + """Collect raw events (filtering happens in _build_stream_metadata).""" + ... + + def _build_stream_metadata( + self, raw_events: list[dict[str, Any]] + ) -> dict[str, Any]: + """Build streaming metadata. Provider API Stream overrides to filter content.""" + return { + "model": self._client.model.id, + "provider": self._client.provider, + "modality": self._client.modality, + "raw_events": raw_events, + } + + def _parse_output( # type: ignore[override] + self, + chunks: list[{Modality}Chunk], + **parameters: Unpack[{Modality}Parameters], + ) -> {Modality}Output: + """Assemble chunks into final output.""" + raw_content = self._aggregate_content(chunks) + content: {Content} = self._transform_output(raw_content, **parameters) + raw_events = self._aggregate_event_data(chunks) + return {Modality}Output( + content=content, + usage=self._aggregate_usage(chunks), + finish_reason=self._aggregate_finish_reason(chunks), + metadata=self._build_stream_metadata(raw_events), + ) + + +__all__ = ["{Modality}Stream"] diff --git a/templates/modalities/{modality_slug}/tests/__init__.py.template b/templates/modalities/{modality_slug}/tests/__init__.py.template new file mode 100644 index 00000000..8d672ef1 --- /dev/null +++ b/templates/modalities/{modality_slug}/tests/__init__.py.template @@ -0,0 +1 @@ +"""Tests for {modality} modality.""" diff --git a/templates/modalities/{modality_slug}/tests/integration_tests/__init__.py.template b/templates/modalities/{modality_slug}/tests/integration_tests/__init__.py.template new file mode 100644 index 00000000..4d978cde --- /dev/null +++ b/templates/modalities/{modality_slug}/tests/integration_tests/__init__.py.template @@ -0,0 +1 @@ +"""Integration tests for {modality} modality.""" diff --git a/templates/modalities/{modality_slug}/tests/integration_tests/conftest.py.template b/templates/modalities/{modality_slug}/tests/integration_tests/conftest.py.template new file mode 100644 index 00000000..728fdbff --- /dev/null +++ b/templates/modalities/{modality_slug}/tests/integration_tests/conftest.py.template @@ -0,0 +1,40 @@ +"""Pytest configuration and fixtures for integration tests.""" + +from collections.abc import AsyncGenerator + +import pytest_asyncio + +from celeste.http import close_all_http_clients + + +@pytest_asyncio.fixture(autouse=True) +async def cleanup_http_clients() -> AsyncGenerator[None, None]: + """Ensure HTTP clients are closed after each test. + + This fixture runs automatically after each test to ensure HTTP clients + are properly closed before pytest-asyncio closes the event loop. + This prevents "Event loop is closed" errors when using pytest-xdist. + """ + yield + await close_all_http_clients() + + +# ============================================================================= +# Add modality-specific fixtures below as needed. +# ============================================================================= +# +# Example: Test assets (images, videos, audio files) +# -------------------------------------------------- +# from pathlib import Path +# import pytest +# from celeste.artifacts import ImageArtifact +# from celeste.mime_types import ImageMimeType +# +# ASSETS_DIR = Path(__file__).parent / "assets" +# +# @pytest.fixture +# def test_image() -> ImageArtifact: +# """Provide a test image.""" +# return ImageArtifact( +# path=str(ASSETS_DIR / "test.png"), mime_type=ImageMimeType.PNG +# ) diff --git a/templates/modalities/{modality_slug}/tests/integration_tests/test_{modality_slug}/__init__.py.template b/templates/modalities/{modality_slug}/tests/integration_tests/test_{modality_slug}/__init__.py.template new file mode 100644 index 00000000..4d978cde --- /dev/null +++ b/templates/modalities/{modality_slug}/tests/integration_tests/test_{modality_slug}/__init__.py.template @@ -0,0 +1 @@ +"""Integration tests for {modality} modality.""" diff --git a/templates/modalities/{modality_slug}/tests/integration_tests/test_{modality_slug}/test_stream_{operation_slug}.py.template b/templates/modalities/{modality_slug}/tests/integration_tests/test_{modality_slug}/test_stream_{operation_slug}.py.template new file mode 100644 index 00000000..53f69f6a --- /dev/null +++ b/templates/modalities/{modality_slug}/tests/integration_tests/test_{modality_slug}/test_stream_{operation_slug}.py.template @@ -0,0 +1,87 @@ +"""Integration tests for streaming {modality} {operation}.""" + +# TODO: REMOVE - See test_{operation}.py for cost guidance on cheap vs expensive modalities. + +import warnings + +# Suppress deprecation warnings from legacy capability packages +warnings.filterwarnings( + "ignore", + message=".*capability parameter is deprecated.*", + category=DeprecationWarning, +) + +import pytest # noqa: E402 + +from celeste import ( # noqa: E402 + Modality, + Model, + Operation, + create_client, + list_models, +) +from celeste.modalities.{modality} import {Modality}Chunk, {Modality}Usage # noqa: E402 + + +@pytest.mark.parametrize( + "model", + [ + m + for m in list_models(modality=Modality.{MODALITY}, operation=Operation.{OPERATION}) + if m.streaming + and not m.optional_input_types # Media-capable models tested in test_stream_*_analyze_* + ], + ids=lambda m: f"{m.provider.value}-{m.id}", +) +@pytest.mark.integration +@pytest.mark.asyncio +async def test_stream_{operation}(model: Model) -> None: + """Test streaming {modality} {operation} for all streaming-capable models. + + Dynamically discovers all streaming models and verifies each can stream. + Failures indicate deprecated or misconfigured models. + """ + client = create_client( + modality=Modality.{MODALITY}, + model=model, + ) + + chunks: list[{Modality}Chunk] = [] + async for chunk in client.stream.{operation}(prompt="Hello"): + chunks.append(chunk) + + # Assert - received at least one chunk + assert chunks, f"Model {model.provider.value}/{model.id} returned no chunks" + + # Assert - all chunks are valid type + assert all(isinstance(c, {Modality}Chunk) for c in chunks), ( + "All chunks should be {Modality}Chunk" + ) + + # Assert - usage in final chunks (provider-dependent) + usage_chunks = [c for c in chunks if c.usage is not None] + if usage_chunks: + usage = usage_chunks[-1].usage + assert isinstance(usage, {Modality}Usage), f"Expected {Modality}Usage, got {type(usage)}" + + +@pytest.mark.integration +def test_sync_stream_{operation}() -> None: + """Test sync streaming wrapper works correctly. + + Single model smoke test - sync stream iteration bridges async internally. + """ + models = [ + m + for m in list_models(modality=Modality.{MODALITY}, operation=Operation.{OPERATION}) + if m.streaming and not m.optional_input_types + ] + model = models[0] + + client = create_client( + modality=Modality.{MODALITY}, + model=model, + ) + + for chunk in client.sync.stream.{operation}(prompt="Hello"): + pass # Just exhaust the stream diff --git a/templates/modalities/{modality_slug}/tests/integration_tests/test_{modality_slug}/test_{operation_slug}.py.template b/templates/modalities/{modality_slug}/tests/integration_tests/test_{modality_slug}/test_{operation_slug}.py.template new file mode 100644 index 00000000..857149b7 --- /dev/null +++ b/templates/modalities/{modality_slug}/tests/integration_tests/test_{modality_slug}/test_{operation_slug}.py.template @@ -0,0 +1,107 @@ +"""Integration tests for {modality} {operation} operation.""" + +# TODO: REMOVE THIS BLOCK AFTER READING ======================================== +# COST GUIDANCE: Choose the appropriate testing strategy based on modality cost. +# +# CHEAP MODALITIES (text, embeddings): +# - Use list_models() for dynamic model discovery (current template) +# - Test ALL registered models automatically +# +# EXPENSIVE MODALITIES (images, videos, audio): +# - Use explicit provider/model pairs in @pytest.mark.parametrize +# - Test ONE model per provider (cheapest option with minimal parameters) +# - Replace the parametrize block with: +# +# @pytest.mark.parametrize( +# ("provider", "model", "parameters"), +# [ +# (Provider.OPENAI, "cheapest-model-id", {}), +# (Provider.GOOGLE, "cheapest-model-id", {"quality": "low"}), +# ], +# ) +# async def test_{operation}(provider: Provider, model: str, parameters: dict): +# client = create_client(modality=Modality.{MODALITY}, provider=provider, model=model) +# response = await client.{operation}(prompt="...", **parameters) +# +# MULTI-MEDIA OPERATIONS: +# - If an operation accepts multiple input types (e.g., ANALYZE with image OR video), +# create separate test files with suffix: test_{operation}_image.py, test_{operation}_video.py +# - Filter by InputType: `InputType.IMAGE in m.optional_input_types` +# +# END TODO ===================================================================== + +import warnings + +# Suppress deprecation warnings from legacy capability packages +warnings.filterwarnings( + "ignore", + message=".*capability parameter is deprecated.*", + category=DeprecationWarning, +) + +import pytest # noqa: E402 + +from celeste import ( # noqa: E402 + Modality, + Model, + Operation, + create_client, + list_models, +) +from celeste.modalities.{modality} import {Modality}Output, {Modality}Usage # noqa: E402 + + +@pytest.mark.parametrize( + "model", + [ + m + for m in list_models(modality=Modality.{MODALITY}, operation=Operation.{OPERATION}) + if not m.streaming # Streaming models tested in test_stream_{operation} + and not m.optional_input_types # Media-capable models tested in test_*_analyze_* + ], + ids=lambda m: f"{m.provider.value}-{m.id}", +) +@pytest.mark.integration +@pytest.mark.asyncio +async def test_{operation}(model: Model) -> None: + """Test {modality} {operation} for all registered models. + + Dynamically discovers all models via list_models() and verifies each one + can {operation}. Failures indicate deprecated or misconfigured models. + """ + client = create_client( + modality=Modality.{MODALITY}, + model=model, + ) + + response = await client.{operation}(prompt="Hello") + + assert isinstance(response, {Modality}Output), ( + f"Expected {Modality}Output, got {type(response)}" + ) + assert response.content is not None, ( + f"Model {model.provider.value}/{model.id} returned None content" + ) + assert isinstance(response.usage, {Modality}Usage), ( + f"Expected {Modality}Usage, got {type(response.usage)}" + ) + + +@pytest.mark.integration +def test_sync_{operation}() -> None: + """Test sync wrapper works correctly. + + Single model smoke test - sync is just async_to_sync wrapper. + """ + models = list_models(modality=Modality.{MODALITY}, operation=Operation.{OPERATION}) + model = models[0] + + client = create_client( + modality=Modality.{MODALITY}, + model=model, + ) + + response = client.sync.{operation}(prompt="Hello") + + assert isinstance(response, {Modality}Output) + assert response.content is not None diff --git a/templates/providers/{provider_slug}/src/celeste_{provider_slug}/__init__.py.template b/templates/providers/{provider_slug}/src/celeste_{provider_slug}/__init__.py.template new file mode 100644 index 00000000..6c4aaa09 --- /dev/null +++ b/templates/providers/{provider_slug}/src/celeste_{provider_slug}/__init__.py.template @@ -0,0 +1,17 @@ +"""{Provider} provider package for Celeste AI.""" + +from celeste.core import Provider +from celeste.credentials import register_auth + +# Register provider auth config when package is imported. +# +# Fill in these placeholders per provider: +# - Provider enum: Provider.{PROVIDER} +# - secret_name: environment variable name (e.g. "OPENAI_API_KEY") +# - header/prefix: auth header format +register_auth( # nosec B106 - env var name, not hardcoded password + provider=Provider.{PROVIDER}, + secret_name="{SECRET_NAME}", + header="{AUTH_HEADER}", + prefix="{AUTH_PREFIX}", +) diff --git a/templates/providers/{provider_slug}/src/celeste_{provider_slug}/py.typed.template b/templates/providers/{provider_slug}/src/celeste_{provider_slug}/py.typed.template new file mode 100644 index 00000000..13290020 --- /dev/null +++ b/templates/providers/{provider_slug}/src/celeste_{provider_slug}/py.typed.template @@ -0,0 +1 @@ +# PEP 561 marker file. diff --git a/templates/providers/{provider_slug}/src/celeste_{provider_slug}/{api_slug}/__init__.py.template b/templates/providers/{provider_slug}/src/celeste_{provider_slug}/{api_slug}/__init__.py.template new file mode 100644 index 00000000..205ee94f --- /dev/null +++ b/templates/providers/{provider_slug}/src/celeste_{provider_slug}/{api_slug}/__init__.py.template @@ -0,0 +1 @@ +"""{Provider} {Api} API provider package.""" diff --git a/templates/providers/{provider_slug}/src/celeste_{provider_slug}/{api_slug}/client.py.template b/templates/providers/{provider_slug}/src/celeste_{provider_slug}/{api_slug}/client.py.template new file mode 100644 index 00000000..aaa75fe1 --- /dev/null +++ b/templates/providers/{provider_slug}/src/celeste_{provider_slug}/{api_slug}/client.py.template @@ -0,0 +1,139 @@ +"""{Provider} {Api} API client mixin.""" + +from collections.abc import AsyncIterator +from typing import Any + +import httpx + +from celeste.client import APIMixin +from celeste.core import UsageField +from celeste.exceptions import StreamingNotSupportedError +from celeste.io import FinishReason +from celeste.mime_types import ApplicationMimeType + +from . import config + + +class {Provider}{Api}Client(APIMixin): + """Mixin for {Provider} {Api} API. + + Provides shared HTTP implementation: + - _make_request(endpoint=...) - HTTP POST to specified endpoint + - _make_stream_request() - HTTP streaming (if supported, otherwise raises StreamingNotSupportedError) + - _parse_usage() - Extract usage dict from response + - _parse_content() - Extract content from response + - _parse_finish_reason() - Extract finish reason (if provided) + - _build_metadata() - Filter content fields + + Modality clients pass endpoint parameter to route operations: + await self._predict(inputs, endpoint=config.{Provider}{Api}Endpoint.CREATE_..., **parameters) + """ + + def _build_request( + self, + inputs: Any, + extra_body: dict[str, Any] | None = None, + streaming: bool = False, + **parameters: Any, + ) -> dict[str, Any]: + """Build request with model ID and streaming flag.""" + request_body = super()._build_request(inputs, extra_body=extra_body, streaming=streaming, **parameters) + request_body["model"] = self.model.id + if streaming: request_body["stream"] = True + return request_body + + async def _make_request( + self, + request_body: dict[str, Any], + *, + endpoint: str | None = None, + **parameters: Any, + ) -> dict[str, Any]: + """Make HTTP request to {Provider} {Api} API.""" + if endpoint is None: + endpoint = config.{Provider}{Api}Endpoint.CREATE_... + + headers = { + **self.auth.get_headers(), + "Content-Type": ApplicationMimeType.JSON, + } + + response = await self.http_client.post( + f"{config.BASE_URL}{endpoint}", + headers=headers, + json_body=request_body, + ) + self._handle_error_response(response) + data: dict[str, Any] = response.json() + return data + + def _make_stream_request( + self, + request_body: dict[str, Any], + *, + endpoint: str | None = None, + **parameters: Any, + ) -> AsyncIterator[dict[str, Any]]: + """Make streaming request to {Provider} {Api} API. + + If this API does not support streaming, replace this implementation with: + def _make_stream_request( + self, + request_body: dict[str, Any], + *, + endpoint: str | None = None, + **parameters: Any, + ) -> AsyncIterator[dict[str, Any]]: + \"\"\"{Provider} {Api} does not support SSE streaming in this client.\"\"\" + raise StreamingNotSupportedError(model_id=self.model.id) + """ + if endpoint is None: + endpoint = config.{Provider}{Api}Endpoint.CREATE_... + + headers = { + **self.auth.get_headers(), + "Content-Type": ApplicationMimeType.JSON, + } + + return self.http_client.stream_post( + f"{config.BASE_URL}{endpoint}", + headers=headers, + json_body=request_body, + ) + + @staticmethod + def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | float | None]: + """Map {Provider} {Api} usage fields to unified names.""" + return { + UsageField.INPUT_TOKENS: usage_data.get("..."), + UsageField.OUTPUT_TOKENS: usage_data.get("..."), + UsageField.TOTAL_TOKENS: usage_data.get("..."), + } + + def _parse_usage(self, response_data: dict[str, Any]) -> dict[str, int | float | None]: + """Extract usage data from {Provider} {Api} API response.""" + usage_data = response_data.get("...", {}) + return {Provider}{Api}Client.map_usage_fields(usage_data) + + def _parse_content(self, response_data: dict[str, Any]) -> Any: + """Parse content from {Provider} {Api} API response.""" + content = response_data.get("...", []) + if not content: + msg = "No content in response" + raise ValueError(msg) + return content + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> FinishReason: + """Extract finish reason from {Provider} {Api} API response.""" + return FinishReason(reason=None) + + def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: + """Build metadata dictionary, filtering out content fields.""" + content_fields = {"..."} + filtered_data = { + k: v for k, v in response_data.items() if k not in content_fields + } + return super()._build_metadata(filtered_data) + + +__all__ = ["{Provider}{Api}Client"] diff --git a/templates/providers/{provider_slug}/src/celeste_{provider_slug}/{api_slug}/config.py.template b/templates/providers/{provider_slug}/src/celeste_{provider_slug}/{api_slug}/config.py.template new file mode 100644 index 00000000..4560a1d1 --- /dev/null +++ b/templates/providers/{provider_slug}/src/celeste_{provider_slug}/{api_slug}/config.py.template @@ -0,0 +1,20 @@ +"""Configuration for {Provider} {Api} API.""" + +from enum import StrEnum + + +class {Provider}{Api}Endpoint(StrEnum): + """Endpoints for {Provider} {Api} API.""" + + # Endpoint names use action verbs: CREATE, GET, LIST, DELETE, STREAM + # Examples: + # CREATE_IMAGE = "/v1/images/generations" + # CREATE_EDIT = "/v1/images/edits" + # CREATE_SPEECH = "/v1/text-to-speech/{voice_id}" + # STREAM_SPEECH = "/v1/text-to-speech/{voice_id}/stream" + # GET_OPERATION = "/v1/{operation_name}" + # LIST_MODELS = "/v1/models" + pass + + +BASE_URL = "https://api.{provider}.com" diff --git a/templates/providers/{provider_slug}/src/celeste_{provider_slug}/{api_slug}/parameters.py.template b/templates/providers/{provider_slug}/src/celeste_{provider_slug}/{api_slug}/parameters.py.template new file mode 100644 index 00000000..ac1d480d --- /dev/null +++ b/templates/providers/{provider_slug}/src/celeste_{provider_slug}/{api_slug}/parameters.py.template @@ -0,0 +1,61 @@ +"""{Provider} {Api} API parameter mappers. + +Naming convention: +- Mapper class name MUST match the provider's API parameter name +- Example: API param "web_search" → class WebSearchMapper (not SearchMapper) +- Example: API param "aspectRatio" → class AspectRatioMapper +- The request key should match the provider's expected field name exactly +""" + +from typing import Any + +from celeste.models import Model +from celeste.parameters import ParameterMapper + + +# PATTERN 1: Simple flat mapping +# Use when provider expects flat request body: {"param": value} +class ExampleParamMapper(ParameterMapper): + """Map example_param to {Provider} example_param field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform example_param into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + # Key matches API parameter name exactly + request["example_param"] = validated_value + return request + + +# PATTERN 2: Nested mapping +# Use when provider expects nested structure: {"parameters": {"param": value}} +class NestedParamMapper(ParameterMapper): + """Map nested_param to {Provider} parameters.nestedParam field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform nested_param into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + # Key matches API parameter name exactly (camelCase for this provider) + request.setdefault("parameters", {})["nestedParam"] = validated_value + return request + + +__all__ = [ + "ExampleParamMapper", + "NestedParamMapper", +] diff --git a/templates/providers/{provider_slug}/src/celeste_{provider_slug}/{api_slug}/streaming.py.template b/templates/providers/{provider_slug}/src/celeste_{provider_slug}/{api_slug}/streaming.py.template new file mode 100644 index 00000000..7c18a9c5 --- /dev/null +++ b/templates/providers/{provider_slug}/src/celeste_{provider_slug}/{api_slug}/streaming.py.template @@ -0,0 +1,62 @@ +"""{Provider} {Api} SSE parsing for streaming.""" + +from typing import Any + +from celeste.io import FinishReason + +from .client import {Provider}{Api}Client + + +class {Provider}{Api}Stream: + """Mixin for {Api} SSE parsing. + + Provides shared implementation for streaming parsing (provider API level): + - _parse_chunk_content(event_data) - Extract content from SSE event + - _parse_chunk_usage(event_data) - Extract and normalize usage from SSE event + - _parse_chunk_finish_reason(event_data) - Extract finish reason from SSE event + + Modality streams call super() methods which resolve to this via MRO. + """ + + def _parse_chunk_content(self, event_data: dict[str, Any]) -> str | None: + """Extract content from SSE event. + + Returns content string if present, None otherwise. + """ + # TODO: Implement provider-specific content extraction + _ = {Provider}{Api}Client # Satisfy import for scaffolding + return None + + def _parse_chunk_usage( + self, event_data: dict[str, Any] + ) -> dict[str, int | float | None] | None: + """Extract and normalize usage from SSE event. + + If usage data is present, map it with: + {Provider}{Api}Client.map_usage_fields(usage_data) + + Returns normalized usage dict if present, None otherwise. + """ + return None + + def _parse_chunk_finish_reason( + self, event_data: dict[str, Any] + ) -> FinishReason | None: + """Extract finish reason from SSE event. + + Returns FinishReason if present, None otherwise. + """ + return None + + def _build_stream_metadata( + self, raw_events: list[dict[str, Any]] + ) -> dict[str, Any]: + """Filter to keep only metadata events (with usage/finish_reason). + + Override to filter out content-only events, keeping events raw. + """ + # TODO: Filter content-only events (e.g., if not event.get("usage"): continue) + return super()._build_stream_metadata(raw_events) # type: ignore[misc] + + +__all__ = ["{Provider}{Api}Stream"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration_tests/__init__.py b/tests/integration_tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration_tests/audio/__init__.py b/tests/integration_tests/audio/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration_tests/audio/test_speak.py b/tests/integration_tests/audio/test_speak.py new file mode 100644 index 00000000..30ebe349 --- /dev/null +++ b/tests/integration_tests/audio/test_speak.py @@ -0,0 +1,93 @@ +"""Integration tests for audio speak operation.""" + +import warnings + +# Suppress deprecation warnings from legacy capability packages +warnings.filterwarnings( + "ignore", + message=".*capability parameter is deprecated.*", + category=DeprecationWarning, +) + +import pytest # noqa: E402 + +from celeste import ( # noqa: E402 + Modality, + Model, + Operation, + create_client, + list_models, +) +from celeste.artifacts import AudioArtifact # noqa: E402 +from celeste.modalities.audio import AudioOutput, AudioUsage # noqa: E402 + + +@pytest.mark.parametrize( + "model", + [ + m + for m in list_models(modality=Modality.AUDIO, operation=Operation.SPEAK) + if not m.streaming # Streaming models tested in test_stream_speak + ], + ids=lambda m: f"{m.provider.value}-{m.id}", +) +@pytest.mark.integration +@pytest.mark.asyncio +async def test_speak(model: Model) -> None: + """Test audio speak for all registered non-streaming models. + + Dynamically discovers all models via list_models() and verifies each one + can speak. Failures indicate deprecated or misconfigured models. + """ + client = create_client( + modality=Modality.AUDIO, + model=model, + ) + + # Get default voice for this model (VoiceConstraint has voices attr) + voice_constraint = model.parameter_constraints.get("voice") + voice = voice_constraint.voices[0].name if voice_constraint else None + + response = await client.speak(text="Hello world", voice=voice) + + assert isinstance(response, AudioOutput), ( + f"Expected AudioOutput, got {type(response)}" + ) + assert isinstance(response.content, AudioArtifact), ( + f"Expected AudioArtifact content, got {type(response.content)}" + ) + assert response.content.has_content, ( + f"Model {model.provider.value}/{model.id} returned AudioArtifact with no content" + ) + assert isinstance(response.usage, AudioUsage), ( + f"Expected AudioUsage, got {type(response.usage)}" + ) + + +@pytest.mark.integration +def test_sync_speak() -> None: + """Test sync wrapper works correctly. + + Single model smoke test - sync is just async_to_sync wrapper. + """ + models = [ + m + for m in list_models(modality=Modality.AUDIO, operation=Operation.SPEAK) + if not m.streaming + ] + model = models[0] + + client = create_client( + modality=Modality.AUDIO, + model=model, + ) + + # Get default voice for this model (VoiceConstraint has voices attr) + voice_constraint = model.parameter_constraints.get("voice") + voice = voice_constraint.voices[0].name if voice_constraint else None + + response = client.sync.speak(text="Hello", voice=voice) + + assert isinstance(response, AudioOutput) + assert isinstance(response.content, AudioArtifact) + assert response.content.has_content diff --git a/tests/integration_tests/audio/test_stream_speak.py b/tests/integration_tests/audio/test_stream_speak.py new file mode 100644 index 00000000..a984f45f --- /dev/null +++ b/tests/integration_tests/audio/test_stream_speak.py @@ -0,0 +1,92 @@ +"""Integration tests for streaming audio speak.""" + +import warnings + +# Suppress deprecation warnings from legacy capability packages +warnings.filterwarnings( + "ignore", + message=".*capability parameter is deprecated.*", + category=DeprecationWarning, +) + +import pytest # noqa: E402 + +from celeste import ( # noqa: E402 + Modality, + Model, + Operation, + create_client, + list_models, +) +from celeste.modalities.audio import AudioChunk, AudioUsage # noqa: E402 + + +@pytest.mark.parametrize( + "model", + [ + m + for m in list_models(modality=Modality.AUDIO, operation=Operation.SPEAK) + if m.streaming + ], + ids=lambda m: f"{m.provider.value}-{m.id}", +) +@pytest.mark.integration +@pytest.mark.asyncio +async def test_stream_speak(model: Model) -> None: + """Test streaming audio speak for all streaming-capable models. + + Dynamically discovers all streaming models and verifies each can stream. + Failures indicate deprecated or misconfigured models. + """ + client = create_client( + modality=Modality.AUDIO, + model=model, + ) + + # Get default voice for this model (VoiceConstraint has voices attr) + voice_constraint = model.parameter_constraints.get("voice") + voice = voice_constraint.voices[0].name if voice_constraint else None + + chunks: list[AudioChunk] = [] + async for chunk in client.stream.speak(text="Hello world", voice=voice): + chunks.append(chunk) + + # Assert - received at least one chunk + assert chunks, f"Model {model.provider.value}/{model.id} returned no chunks" + + # Assert - all chunks are valid type + assert all(isinstance(c, AudioChunk) for c in chunks), ( + "All chunks should be AudioChunk" + ) + + # Assert - usage in final chunks (provider-dependent) + usage_chunks = [c for c in chunks if c.usage is not None] + if usage_chunks: + usage = usage_chunks[-1].usage + assert isinstance(usage, AudioUsage), f"Expected AudioUsage, got {type(usage)}" + + +@pytest.mark.integration +def test_sync_stream_speak() -> None: + """Test sync streaming wrapper works correctly. + + Single model smoke test - sync stream iteration bridges async internally. + """ + models = [ + m + for m in list_models(modality=Modality.AUDIO, operation=Operation.SPEAK) + if m.streaming + ] + model = models[0] + + client = create_client( + modality=Modality.AUDIO, + model=model, + ) + + # Get default voice for this model (VoiceConstraint has voices attr) + voice_constraint = model.parameter_constraints.get("voice") + voice = voice_constraint.voices[0].name if voice_constraint else None + + for _chunk in client.sync.stream.speak(text="Hello", voice=voice): + pass # Just exhaust the stream diff --git a/packages/capabilities/speech-generation/tests/integration_tests/conftest.py b/tests/integration_tests/conftest.py similarity index 100% rename from packages/capabilities/speech-generation/tests/integration_tests/conftest.py rename to tests/integration_tests/conftest.py diff --git a/tests/integration_tests/embeddings/__init__.py b/tests/integration_tests/embeddings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration_tests/embeddings/test_embed.py b/tests/integration_tests/embeddings/test_embed.py new file mode 100644 index 00000000..c558c04f --- /dev/null +++ b/tests/integration_tests/embeddings/test_embed.py @@ -0,0 +1,107 @@ +"""Integration tests for embeddings embed operation.""" + +import warnings + +# Suppress deprecation warnings from legacy capability packages +warnings.filterwarnings( + "ignore", + message=".*capability parameter is deprecated.*", + category=DeprecationWarning, +) + +import pytest # noqa: E402 + +from celeste import ( # noqa: E402 + Modality, + Model, + Operation, + create_client, + list_models, +) +from celeste.modalities.embeddings import ( # noqa: E402 + EmbeddingsOutput, + EmbeddingsUsage, +) + + +@pytest.mark.parametrize( + "model", + [m for m in list_models(modality=Modality.EMBEDDINGS, operation=Operation.EMBED)], + ids=lambda m: f"{m.provider.value}-{m.id}", +) +@pytest.mark.integration +@pytest.mark.asyncio +async def test_embed_single(model: Model) -> None: + """Test embeddings for single text input.""" + client = create_client( + modality=Modality.EMBEDDINGS, + model=model, + ) + + response = await client.embed("Hello world") + + assert isinstance(response, EmbeddingsOutput), ( + f"Expected EmbeddingsOutput, got {type(response)}" + ) + assert response.content is not None, ( + f"Model {model.provider.value}/{model.id} returned None content" + ) + # Single text input should return list[float] + assert isinstance(response.content, list), "Content should be a list" + assert len(response.content) > 0, "Embedding vector should not be empty" + assert isinstance(response.content[0], float), ( + "Single text should return list[float], not list[list[float]]" + ) + assert isinstance(response.usage, EmbeddingsUsage), ( + f"Expected EmbeddingsUsage, got {type(response.usage)}" + ) + + +@pytest.mark.parametrize( + "model", + [m for m in list_models(modality=Modality.EMBEDDINGS, operation=Operation.EMBED)], + ids=lambda m: f"{m.provider.value}-{m.id}", +) +@pytest.mark.integration +@pytest.mark.asyncio +async def test_embed_batch(model: Model) -> None: + """Test embeddings for batch text input.""" + client = create_client( + modality=Modality.EMBEDDINGS, + model=model, + ) + + response = await client.embed(["Hello", "World"]) + + assert isinstance(response, EmbeddingsOutput), ( + f"Expected EmbeddingsOutput, got {type(response)}" + ) + assert response.content is not None, ( + f"Model {model.provider.value}/{model.id} returned None content" + ) + # Batch text input should return list[list[float]] + assert isinstance(response.content, list), "Content should be a list" + assert len(response.content) == 2, "Should have 2 embeddings for 2 inputs" + assert isinstance(response.content[0], list), ( + "Batch text should return list[list[float]]" + ) + assert isinstance(response.content[0][0], float), ( + "Each embedding should be list[float]" + ) + + +@pytest.mark.integration +def test_sync_embed() -> None: + """Test sync wrapper works correctly.""" + models = list_models(modality=Modality.EMBEDDINGS, operation=Operation.EMBED) + model = models[0] + + client = create_client( + modality=Modality.EMBEDDINGS, + model=model, + ) + + response = client.sync.embed("Hello") + + assert isinstance(response, EmbeddingsOutput) + assert response.content is not None diff --git a/tests/integration_tests/images/__init__.py b/tests/integration_tests/images/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration_tests/images/assets/square.png b/tests/integration_tests/images/assets/square.png new file mode 100644 index 0000000000000000000000000000000000000000..f391f9cf7babe2a54536b06e59a4a794a1262242 GIT binary patch literal 623 zcmeAS@N?(olHy`uVBq!ia0vp^CqS5k2}mkgS)OEIU^4S`aSW-L^Y-pWUS>rRR>uwN z{!iP_T%J&w>SrFzpcD~zYTiuN{oj85>{QUv66;>gy%E#Lor7k!soFE{<3%{IWK*^bEJ;YAtl8@K{!R+X+KJ9w-l~zep?^kupa2|>a ImageArtifact: + """Provide a square shape test image.""" + return ImageArtifact( + path=str(ASSETS_DIR / "square.png"), mime_type=ImageMimeType.PNG + ) diff --git a/tests/integration_tests/images/test_edit.py b/tests/integration_tests/images/test_edit.py new file mode 100644 index 00000000..5eaa0cdb --- /dev/null +++ b/tests/integration_tests/images/test_edit.py @@ -0,0 +1,77 @@ +"""Integration tests for images edit operation across providers.""" + +import warnings + +# Suppress deprecation warnings from legacy capability packages +warnings.filterwarnings( + "ignore", + message=".*capability parameter is deprecated.*", + category=DeprecationWarning, +) + +import pytest # noqa: E402 + +from celeste import Modality, Provider, create_client # noqa: E402 +from celeste.artifacts import ImageArtifact # noqa: E402 +from celeste.modalities.images import ImageOutput, ImageUsage # noqa: E402 + + +@pytest.mark.parametrize( + ("provider", "model"), + [ + (Provider.OPENAI, "gpt-image-1-mini"), + (Provider.GOOGLE, "gemini-2.5-flash-image"), + (Provider.BFL, "flux-2-pro"), + ], +) +@pytest.mark.integration +@pytest.mark.asyncio +async def test_edit( + provider: Provider, model: str, square_image: ImageArtifact +) -> None: + """Test image editing across providers. + + Uses cheapest edit-capable model per provider. + """ + client = create_client( + modality=Modality.IMAGES, + provider=provider, + model=model, + ) + + response = await client.edit( + image=square_image, + prompt="Add a small blue circle in the center", + ) + + assert isinstance(response, ImageOutput), ( + f"Expected ImageOutput, got {type(response)}" + ) + assert isinstance(response.content, ImageArtifact), ( + f"Expected ImageArtifact content, got {type(response.content)}" + ) + assert response.content.has_content, ( + f"ImageArtifact has no content (url/data/path): {response.content}" + ) + assert isinstance(response.usage, ImageUsage), ( + f"Expected ImageUsage, got {type(response.usage)}" + ) + + +@pytest.mark.integration +def test_sync_edit(square_image: ImageArtifact) -> None: + """Test sync edit wrapper works correctly. + + Single model smoke test - sync is just async_to_sync wrapper. + """ + client = create_client( + modality=Modality.IMAGES, + provider=Provider.GOOGLE, + model="gemini-2.5-flash-image", + ) + + response = client.sync.edit(image=square_image, prompt="Add a red border") + + assert isinstance(response, ImageOutput) + assert isinstance(response.content, ImageArtifact) + assert response.content.has_content diff --git a/tests/integration_tests/images/test_generate.py b/tests/integration_tests/images/test_generate.py new file mode 100644 index 00000000..d7c15e59 --- /dev/null +++ b/tests/integration_tests/images/test_generate.py @@ -0,0 +1,76 @@ +"""Integration tests for images generate operation across all providers.""" + +import warnings + +# Suppress deprecation warnings from legacy capability packages +warnings.filterwarnings( + "ignore", + message=".*capability parameter is deprecated.*", + category=DeprecationWarning, +) + +import pytest # noqa: E402 + +from celeste import Modality, Provider, create_client # noqa: E402 +from celeste.artifacts import ImageArtifact # noqa: E402 +from celeste.modalities.images import ImageOutput, ImageUsage # noqa: E402 + + +@pytest.mark.parametrize( + ("provider", "model", "parameters"), + [ + (Provider.OPENAI, "dall-e-2", {}), + (Provider.GOOGLE, "imagen-4.0-fast-generate-001", {"num_images": 1}), + (Provider.BYTEPLUS, "seedream-4-0-250828", {}), + (Provider.BFL, "flux-2-pro", {}), + ], +) +@pytest.mark.integration +@pytest.mark.asyncio +async def test_generate(provider: Provider, model: str, parameters: dict) -> None: + """Test image generation across providers. + + Uses cheapest model per provider to minimize costs. + """ + client = create_client( + modality=Modality.IMAGES, + provider=provider, + model=model, + ) + + response = await client.generate( + prompt="A red apple on a white background", + **parameters, + ) + + assert isinstance(response, ImageOutput), ( + f"Expected ImageOutput, got {type(response)}" + ) + assert isinstance(response.content, ImageArtifact), ( + f"Expected ImageArtifact content, got {type(response.content)}" + ) + assert response.content.has_content, ( + f"ImageArtifact has no content (url/data/path): {response.content}" + ) + assert isinstance(response.usage, ImageUsage), ( + f"Expected ImageUsage, got {type(response.usage)}" + ) + + +@pytest.mark.integration +def test_sync_generate() -> None: + """Test sync wrapper works correctly. + + Single model smoke test - sync is just async_to_sync wrapper. + """ + client = create_client( + modality=Modality.IMAGES, + provider=Provider.GOOGLE, + model="imagen-4.0-fast-generate-001", + ) + + response = client.sync.generate(prompt="A red circle") + + assert isinstance(response, ImageOutput) + assert isinstance(response.content, ImageArtifact) + assert response.content.has_content diff --git a/tests/integration_tests/images/test_stream_edit.py b/tests/integration_tests/images/test_stream_edit.py new file mode 100644 index 00000000..f58a5dda --- /dev/null +++ b/tests/integration_tests/images/test_stream_edit.py @@ -0,0 +1,93 @@ +"""Integration tests for streaming image editing across providers.""" + +import warnings + +# Suppress deprecation warnings from legacy capability packages +warnings.filterwarnings( + "ignore", + message=".*capability parameter is deprecated.*", + category=DeprecationWarning, +) + +import pytest # noqa: E402 + +from celeste import Modality, Provider, create_client # noqa: E402 +from celeste.artifacts import ImageArtifact # noqa: E402 +from celeste.modalities.images import ImageChunk, ImageUsage # noqa: E402 + + +@pytest.mark.parametrize( + ("provider", "model"), + [ + (Provider.OPENAI, "gpt-image-1-mini"), + # BytePlus: edit not implemented yet + # Google/BFL: streaming not supported + ], +) +@pytest.mark.integration +@pytest.mark.asyncio +async def test_stream_edit( + provider: Provider, model: str, square_image: ImageArtifact +) -> None: + """Test streaming image editing across providers. + + Uses cheapest streaming + edit capable model per provider. + """ + client = create_client( + modality=Modality.IMAGES, + provider=provider, + model=model, + ) + + chunks: list[ImageChunk] = [] + async for chunk in client.stream.edit( + image=square_image, + prompt="Add a small blue circle in the center", + ): + chunks.append(chunk) + + assert len(chunks) > 0, "Expected at least one chunk" + assert all(isinstance(c, ImageChunk) for c in chunks), ( + "All chunks should be ImageChunk" + ) + + final_chunk = chunks[-1] + assert isinstance(final_chunk.content, ImageArtifact), ( + f"Expected ImageArtifact, got {type(final_chunk.content)}" + ) + assert final_chunk.content.has_content, ( + f"Final chunk ImageArtifact has no content: {final_chunk.content}" + ) + + usage_chunks = [c for c in chunks if c.usage is not None] + if usage_chunks: + assert isinstance(usage_chunks[-1].usage, ImageUsage), ( + f"Expected ImageUsage, got {type(usage_chunks[-1].usage)}" + ) + + +@pytest.mark.integration +def test_sync_stream_edit(square_image: ImageArtifact) -> None: + """Test sync streaming wrapper works correctly. + + Single model smoke test - sync stream iteration bridges async internally. + """ + from celeste import Model, Operation, list_models + + models = [ + m + for m in list_models(modality=Modality.IMAGES, operation=Operation.EDIT) + if m.streaming + ] + model: Model = models[0] + + client = create_client( + modality=Modality.IMAGES, + model=model, + ) + + for _chunk in client.sync.stream.edit( + image=square_image, + prompt="Add a small blue circle in the center", + ): + pass # Just exhaust the stream diff --git a/tests/integration_tests/images/test_stream_generate.py b/tests/integration_tests/images/test_stream_generate.py new file mode 100644 index 00000000..191ba130 --- /dev/null +++ b/tests/integration_tests/images/test_stream_generate.py @@ -0,0 +1,88 @@ +"""Integration tests for streaming image generation across providers.""" + +import warnings + +# Suppress deprecation warnings from legacy capability packages +warnings.filterwarnings( + "ignore", + message=".*capability parameter is deprecated.*", + category=DeprecationWarning, +) + +import pytest # noqa: E402 + +from celeste import Modality, Provider, create_client # noqa: E402 +from celeste.artifacts import ImageArtifact # noqa: E402 +from celeste.modalities.images import ImageChunk, ImageUsage # noqa: E402 + + +@pytest.mark.parametrize( + ("provider", "model"), + [ + (Provider.OPENAI, "gpt-image-1-mini"), + (Provider.BYTEPLUS, "seedream-4-0-250828"), + ], +) +@pytest.mark.integration +@pytest.mark.asyncio +async def test_stream_generate(provider: Provider, model: str) -> None: + """Test streaming image generation across providers. + + Uses cheapest streaming-capable model per provider. + """ + client = create_client( + modality=Modality.IMAGES, + provider=provider, + model=model, + ) + + chunks: list[ImageChunk] = [] + async for chunk in client.stream.generate( + prompt="A red apple on a white background" + ): + chunks.append(chunk) + + assert len(chunks) > 0, "Expected at least one chunk" + assert all(isinstance(c, ImageChunk) for c in chunks), ( + "All chunks should be ImageChunk" + ) + + final_chunk = chunks[-1] + assert isinstance(final_chunk.content, ImageArtifact), ( + f"Expected ImageArtifact, got {type(final_chunk.content)}" + ) + assert final_chunk.content.has_content, ( + f"Final chunk ImageArtifact has no content: {final_chunk.content}" + ) + + usage_chunks = [c for c in chunks if c.usage is not None] + if usage_chunks: + assert isinstance(usage_chunks[-1].usage, ImageUsage), ( + f"Expected ImageUsage, got {type(usage_chunks[-1].usage)}" + ) + + +@pytest.mark.integration +def test_sync_stream_generate() -> None: + """Test sync streaming wrapper works correctly. + + Single model smoke test - sync stream iteration bridges async internally. + """ + from celeste import Model, Operation, list_models + + models = [ + m + for m in list_models(modality=Modality.IMAGES, operation=Operation.GENERATE) + if m.streaming + ] + model: Model = models[0] + + client = create_client( + modality=Modality.IMAGES, + model=model, + ) + + for _chunk in client.sync.stream.generate( + prompt="A red apple on a white background" + ): + pass # Just exhaust the stream diff --git a/tests/integration_tests/text/__init__.py b/tests/integration_tests/text/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration_tests/text/assets/square.png b/tests/integration_tests/text/assets/square.png new file mode 100644 index 0000000000000000000000000000000000000000..f391f9cf7babe2a54536b06e59a4a794a1262242 GIT binary patch literal 623 zcmeAS@N?(olHy`uVBq!ia0vp^CqS5k2}mkgS)OEIU^4S`aSW-L^Y-pWUS>rRR>uwN z{!iP_T%J&w>SrFzpcD~zYTiuN{oj85>{QUv66;>gy%E#Lor7k!soFE{<3%{IWK*^bEJ;YAtl8@K{!R+X+KJ9w-l~zep?^kupa2|>a_B|Wfqjd5Cb9zhCz)MHB1T!hy#igF(?8GD2PL9m0l7Mv>=o+{Z?zl zI8ni1)l#azAw&?Iz&fB{4HgwqTeQ|f8GPs73+HSHCcxL7 z2+)hRbjcDw`2QFH2tm7TM8j@nFT>Yuh=Z>T;J*;K3JsWA zSlZaw(kD4OIy2ner_c0cv3%wQ1OzQy92OS-X;f5nOx*hQNy(|HTR+=@AiQ0>b8_+u zii%3V`S#Eu!Ljo43gP$X&i!~^B&w@#yn6M<&D*zI+kWZj=zQAU{j%qew{QCf-l53w z8P~vPbe)ObC@wmYDSJtyIVB-Y*z0%wdlHd*4IA`BP(~Fqm=NWnCDr|ofG+cV}+F$%Y(dv{oGMa z%ip^ONCQ3S5^nSZW0;-<$6fhlw#L7;+C(9MIvy+}@VSp{xv$G^d~kvH)+;YptmtZI zl@;Mco~~Es?Q4IP?{pG?eMEMq==glr$!pOozX($<8K&tPAP*`YlEQSbzpZO)ud{nw zynz!7P@UN&>8=5rr2EqZ?EA_kRi5hoHwH-kQj|A+uU}|{SF)S*gu6q=y+X!(U?197`$Tze0o2Jk9+ zr!({{a=|24X>1fNA-QzZz$;o;xpE%^>+5y6L<)T8iZHixLJi{GNus1Y$tR6T-XVM*Z|LMcjpuI zJWG=~$O2UyL@b0jt~7QauYyy|2>LkA09huRt5v}sLDZN?x3>Ygv6uBj5a8+G=O)dL z6VJTQTg@C93XBgTu7)_X>C^?hit1QTz4xR1-u*v(mgum<&6bAvzUn@B4|k#&5a(Ba z!AfMjj;?)#b5Zg^e_f7q-a4h;nZ|~Zt8NhFytnTAw+cc!$ff4by?c&IJk~GF{V9h0 zL$P<@w}IO za+;9J26}nIgPQ8T&J_;CDn}Yl^3BWWnwtGR~Pw^Cc z5g2bZo&M@poXE3SytVs7IL=(^eBR04SPQH7g&KK3@DCk*#uO0aN6Pdonpq5u}#wU0F<+4ywL}@u{{>kyIg6A6^nCmJu{kCzZQFt>+j}w4Y1h3A6MtuxXp;P?BL} zE5O^{T^C$LG2RBgq7(ZS!keq1V@J zptaZ2xFuxYJ$6Xo1v@1lg>D!h7$2(CAI7I%==*WM4QR=HU`xsID{vyU8eUni-}1JF zbPWL}+QG!F!6w_iTRT@3sl$Q!T2g_q=Rure4LAxI8YPI-<0H!l4HY!@d>Ee+fd<_MwAwJc!SLi7JA=*m&KJ`+y7{^|08kp( znJXNy>~VJz?YgGI2jal}gW~w1d;}VGdw}843`%vSkwCw|z^vFbt$7UGKgPRrc2{q+ z3}JNE=~GW>dHL1iqx)Rvzd$;>0B-lpn_IjXxf!H+ z3ofl!%jdW(J^{Q@?9)MtOg9g0=3#%^l|=VxlZ~t#Mu2BSV4t|mJR{FTmpqfjeFO@hW z_-sMz51J1k&PVgk09ghToec^GPQ2c{k0BZ8OOO)L54I=M!EsyK*P z1p6IW7yMx#0_)tUa3GzOvVw&^!BLkF#@&_SWE4r-wFy7#U3qC1vQr(7z1{IZTQsOB5 z2J->TqiBDC_ScsL87dYV{zgAh1~bJdg`7wGn~*v!YwdrZ6JaSQ*wVYaMADLLp{8r5r4zoaF;8|NP7|=2|;wD;Cod zm3+|UVPdeYMrpZjtcp>K3B$$J*=bwu6wL)cLgAIjZqEaiW*G^m_ie zYwxlNVtTUdh6riN0GHjl$?+@BIN|FPKR;IW0n`n}fxbrl26aOX^5N?;C>5bF;XY5y zfqeKe0eR34Kn(M!sYpoP=h49!=dQL75m+Z+`-f1;si$l30X8XsILQaZJ8{%HiBD!d z(PWSUXH$fN(~Fkni@%}VI31MWs2U&ie7X_!0n`opV3auiYo3;n56_u&pmd7OT-@hy zzF5dCNpIJstWR&qHQuYXKiF@bRPQO_2g3L?lxoloRkT@`0cf*94bC z4uB5Qlp7yq+PbSE;MEVReBeGsAfF04H2~F(;XAzCsJ^~zc$|;M$A5LSxw+9nQf%U+ zjV~ttg#f**LW$LQQj-?HWzpJ&s`(A#WLPIigknD2Kb%V%`q8k&QaH zKI<&7nF7J+nZs>b56*wt2Em!{ij?s|1;FYL5a(FO)iFfUAQVTSVeZmsD8q?gacx}6 z-!r5(3!*>yB6`Ah00M26=rhOaZZ_`932&)U;wbVCmJimU=SHYouSUP!$2AF2-C`m| zGwOtGqG;QhxIEz(=V5%X$%Qx^PA+^()bh!;PC)qtL%%^Bl<0@Ad(=h@Cw|2ft0{q= zmL;5!T!L(4W(n4XlU=`>G7vJoEP73SkDB=T01sIOKUHk$nFy_ZDM`~`cr#}V2bes#J*PLXI zO)j=8`3&>*e#qyD6!xQ)oFWbLH<+$PXgYA6G;3$5y=ztUV$+6CTlIg0cH5;ZeUi!F zfV5xj8F;9^ZjTfR{dQ*z_YUV=D&`{MV5<-V^e#`>l>4tL6i=&RMRLRAf-G z`lHDQ3LN|F>j#%w!eK$AN2tdT?=1)5%1rinaLHY^|Ea#PN^O3V?^Ec5>P!u~m3QN0 z=Mv+LC6AKQaV5GxpLEk*g|xxKAiVDt1^?d~$BEc+=D;E5 zXDR6^1yy%Y94?IyA zyDGl?hoRc70{~|`p6h8w(B=71?u?!D0i1e9J=GuL#H!Klx%>#{YFxZK!kRaINf9-IO@?y`L42m!}?J)zZHAxjq-Q@&DZ2O0@zFMQ?@Xtrkv7cwmQ7MV#sA$4l+SN~)D^t8Ps9rKuO8i9S5;541AtBdZgPubN$q}m%qBU z_Q5ODpYdxae*CHBEX?y6?xjj{8&J*J)*_#?rM(m^!LU|_!|c;%PMw~a=PPGd;ps~c zhSfA{M=~(nv8=fn$8zSNtQ(V6W@opzx650hFVjdQJI|vuZHPLA>Og069fcm2GEi7NQF#e3jD?|Pgo%+V5^#dP z7B!V*z~dSkL9K+~oes*C_~YjJ$6x=~;P+n9btf;1@;e3U z6lkwN`vdeED-bWxa)H(hv{Rr?f%XctKR~;zK)gW91zIoAPJucF+AGlh0R8XRH~K@I zrdv4ODBi03;6pU}4P$qH!b*%jx9D#I)Q9BdTb{Q=BaDqcfNyLA#urQ@uQ@v8*w2Q# zhv`tA+t%$MsFep!N#AIoNf~Lp>^UJrKX0CE-D?BU_7hwG|DmeK7fXgciO@2R&;N(m?Nj|?TDqzrc;z#VuLSHSuU Dq31f< literal 0 HcmV?d00001 diff --git a/tests/integration_tests/text/conftest.py b/tests/integration_tests/text/conftest.py new file mode 100644 index 00000000..4d468a77 --- /dev/null +++ b/tests/integration_tests/text/conftest.py @@ -0,0 +1,34 @@ +"""Text modality integration test fixtures.""" + +from pathlib import Path + +import pytest + +from celeste.artifacts import AudioArtifact, ImageArtifact, VideoArtifact +from celeste.mime_types import AudioMimeType, ImageMimeType, VideoMimeType + +ASSETS_DIR = Path(__file__).parent / "assets" + + +@pytest.fixture +def square_image() -> ImageArtifact: + """Provide a square shape test image.""" + return ImageArtifact( + path=str(ASSETS_DIR / "square.png"), mime_type=ImageMimeType.PNG + ) + + +@pytest.fixture +def test_video() -> VideoArtifact: + """Provide a minimal test video (2s blue screen, 160x120).""" + return VideoArtifact( + path=str(ASSETS_DIR / "test_video.mp4"), mime_type=VideoMimeType.MP4 + ) + + +@pytest.fixture +def test_audio() -> AudioArtifact: + """Provide a minimal test audio (2s 440Hz sine wave).""" + return AudioArtifact( + path=str(ASSETS_DIR / "test_audio.mp3"), mime_type=AudioMimeType.MP3 + ) diff --git a/tests/integration_tests/text/test_analyze_audio.py b/tests/integration_tests/text/test_analyze_audio.py new file mode 100644 index 00000000..331c25a6 --- /dev/null +++ b/tests/integration_tests/text/test_analyze_audio.py @@ -0,0 +1,90 @@ +"""Integration tests for text analyze operation - all audio-capable models.""" + +import warnings + +# Suppress deprecation warnings from legacy capability packages +warnings.filterwarnings( + "ignore", + message=".*capability parameter is deprecated.*", + category=DeprecationWarning, +) + +import pytest # noqa: E402 + +from celeste import ( # noqa: E402 + Modality, + Model, + Operation, + create_client, + list_models, +) +from celeste.artifacts import AudioArtifact # noqa: E402 +from celeste.core import InputType # noqa: E402 +from celeste.modalities.text import TextOutput, TextUsage # noqa: E402 + + +@pytest.mark.parametrize( + "model", + [ + m + for m in list_models(modality=Modality.TEXT, operation=Operation.ANALYZE) + if not m.streaming and InputType.AUDIO in m.optional_input_types + ], + ids=lambda m: f"{m.provider.value}-{m.id}", +) +@pytest.mark.integration +@pytest.mark.asyncio +async def test_analyze_audio(model: Model, test_audio: AudioArtifact) -> None: + """Test audio analysis for all audio-capable models. + + Dynamically discovers all audio models via list_models() and verifies each + can analyze audio. Failures indicate deprecated or misconfigured models. + """ + client = create_client( + modality=Modality.TEXT, + model=model, + ) + + response = await client.analyze( + prompt="Describe what you hear in this audio", + audio=test_audio, + ) + + assert isinstance(response, TextOutput), ( + f"Expected TextOutput, got {type(response)}" + ) + # Empty/None content is valid for reasoning models that use all tokens for thinking + if not response.content: + assert response.finish_reason is not None, ( + f"Model {model.provider.value}/{model.id} returned empty content without finish_reason" + ) + assert isinstance(response.usage, TextUsage), ( + f"Expected TextUsage, got {type(response.usage)}" + ) + + +@pytest.mark.integration +def test_sync_analyze_audio(test_audio: AudioArtifact) -> None: + """Test sync wrapper works correctly. + + Single model smoke test - sync is just async_to_sync wrapper. + """ + models = [ + m + for m in list_models(modality=Modality.TEXT, operation=Operation.ANALYZE) + if InputType.AUDIO in m.optional_input_types + ] + model = models[0] + + client = create_client( + modality=Modality.TEXT, + model=model, + ) + + response = client.sync.analyze( + prompt="Describe what you hear in this audio", + audio=test_audio, + ) + + assert isinstance(response, TextOutput) + assert response.content or response.finish_reason is not None diff --git a/tests/integration_tests/text/test_analyze_image.py b/tests/integration_tests/text/test_analyze_image.py new file mode 100644 index 00000000..70917785 --- /dev/null +++ b/tests/integration_tests/text/test_analyze_image.py @@ -0,0 +1,98 @@ +"""Integration tests for text analyze operation - all vision-capable models.""" + +import warnings + +# Suppress deprecation warnings from legacy capability packages +warnings.filterwarnings( + "ignore", + message=".*capability parameter is deprecated.*", + category=DeprecationWarning, +) + +import pytest # noqa: E402 + +from celeste import ( # noqa: E402 + Modality, + Model, + Operation, + create_client, + list_models, +) +from celeste.artifacts import ImageArtifact # noqa: E402 +from celeste.core import InputType # noqa: E402 +from celeste.modalities.text import TextOutput, TextUsage # noqa: E402 + +TEST_MAX_TOKENS = 200 + + +@pytest.mark.parametrize( + "model", + [ + m + for m in list_models(modality=Modality.TEXT, operation=Operation.ANALYZE) + if not m.streaming and InputType.IMAGE in m.optional_input_types + ], + ids=lambda m: f"{m.provider.value}-{m.id}", +) +@pytest.mark.integration +@pytest.mark.asyncio +async def test_analyze(model: Model, square_image: ImageArtifact) -> None: + """Test image analysis for all vision-capable models. + + Dynamically discovers all vision models via list_models() and verifies each + can analyze images. Failures indicate deprecated or misconfigured models. + """ + client = create_client( + modality=Modality.TEXT, + model=model, + ) + + response = await client.analyze( + prompt="What is this?", + image=square_image, + max_tokens=TEST_MAX_TOKENS, + ) + + assert isinstance(response, TextOutput), ( + f"Expected TextOutput, got {type(response)}" + ) + # Empty/None content is valid for reasoning models that use all tokens for thinking + if not response.content: + assert response.finish_reason is not None, ( + f"Model {model.provider.value}/{model.id} returned empty content without finish_reason" + ) + assert isinstance(response.usage, TextUsage), ( + f"Expected TextUsage, got {type(response.usage)}" + ) + if response.usage.output_tokens is not None: + assert response.usage.output_tokens <= TEST_MAX_TOKENS, ( + f"Model {model.provider.value}/{model.id} exceeded max_tokens: {response.usage.output_tokens} > {TEST_MAX_TOKENS}" + ) + + +@pytest.mark.integration +def test_sync_analyze(square_image: ImageArtifact) -> None: + """Test sync wrapper works correctly. + + Single model smoke test - sync is just async_to_sync wrapper. + """ + models = [ + m + for m in list_models(modality=Modality.TEXT, operation=Operation.ANALYZE) + if InputType.IMAGE in m.optional_input_types + ] + model = models[0] + + client = create_client( + modality=Modality.TEXT, + model=model, + ) + + response = client.sync.analyze( + prompt="What is this?", + image=square_image, + max_tokens=TEST_MAX_TOKENS, + ) + + assert isinstance(response, TextOutput) + assert response.content or response.finish_reason is not None diff --git a/tests/integration_tests/text/test_analyze_video.py b/tests/integration_tests/text/test_analyze_video.py new file mode 100644 index 00000000..5a286914 --- /dev/null +++ b/tests/integration_tests/text/test_analyze_video.py @@ -0,0 +1,90 @@ +"""Integration tests for text analyze operation - all video-capable models.""" + +import warnings + +# Suppress deprecation warnings from legacy capability packages +warnings.filterwarnings( + "ignore", + message=".*capability parameter is deprecated.*", + category=DeprecationWarning, +) + +import pytest # noqa: E402 + +from celeste import ( # noqa: E402 + Modality, + Model, + Operation, + create_client, + list_models, +) +from celeste.artifacts import VideoArtifact # noqa: E402 +from celeste.core import InputType # noqa: E402 +from celeste.modalities.text import TextOutput, TextUsage # noqa: E402 + + +@pytest.mark.parametrize( + "model", + [ + m + for m in list_models(modality=Modality.TEXT, operation=Operation.ANALYZE) + if not m.streaming and InputType.VIDEO in m.optional_input_types + ], + ids=lambda m: f"{m.provider.value}-{m.id}", +) +@pytest.mark.integration +@pytest.mark.asyncio +async def test_analyze_video(model: Model, test_video: VideoArtifact) -> None: + """Test video analysis for all video-capable models. + + Dynamically discovers all video models via list_models() and verifies each + can analyze videos. Failures indicate deprecated or misconfigured models. + """ + client = create_client( + modality=Modality.TEXT, + model=model, + ) + + response = await client.analyze( + prompt="Describe what happens in this video", + video=test_video, + ) + + assert isinstance(response, TextOutput), ( + f"Expected TextOutput, got {type(response)}" + ) + # Empty/None content is valid for reasoning models that use all tokens for thinking + if not response.content: + assert response.finish_reason is not None, ( + f"Model {model.provider.value}/{model.id} returned empty content without finish_reason" + ) + assert isinstance(response.usage, TextUsage), ( + f"Expected TextUsage, got {type(response.usage)}" + ) + + +@pytest.mark.integration +def test_sync_analyze_video(test_video: VideoArtifact) -> None: + """Test sync wrapper works correctly. + + Single model smoke test - sync is just async_to_sync wrapper. + """ + models = [ + m + for m in list_models(modality=Modality.TEXT, operation=Operation.ANALYZE) + if InputType.VIDEO in m.optional_input_types + ] + model = models[0] + + client = create_client( + modality=Modality.TEXT, + model=model, + ) + + response = client.sync.analyze( + prompt="Describe what happens in this video", + video=test_video, + ) + + assert isinstance(response, TextOutput) + assert response.content or response.finish_reason is not None diff --git a/tests/integration_tests/text/test_generate.py b/tests/integration_tests/text/test_generate.py new file mode 100644 index 00000000..d1d21ecc --- /dev/null +++ b/tests/integration_tests/text/test_generate.py @@ -0,0 +1,85 @@ +"""Integration tests for text generate operation - all registered models.""" + +import warnings + +# Suppress deprecation warnings from legacy capability packages +warnings.filterwarnings( + "ignore", + message=".*capability parameter is deprecated.*", + category=DeprecationWarning, +) + +import pytest # noqa: E402 + +from celeste import ( # noqa: E402 + Modality, + Model, + Operation, + create_client, + list_models, +) +from celeste.modalities.text import TextOutput, TextUsage # noqa: E402 + +TEST_MAX_TOKENS = 200 + + +@pytest.mark.parametrize( + "model", + [ + m + for m in list_models(modality=Modality.TEXT, operation=Operation.GENERATE) + if not m.streaming # Streaming models tested in test_stream_generate + and not m.optional_input_types # Media-capable models tested in test_analyze_* + ], + ids=lambda m: f"{m.provider.value}-{m.id}", +) +@pytest.mark.integration +@pytest.mark.asyncio +async def test_generate(model: Model) -> None: + """Test text generation for all registered models. + + Dynamically discovers all models via list_models() and verifies each one + can generate text. Failures indicate deprecated or misconfigured models. + """ + client = create_client( + modality=Modality.TEXT, + model=model, + ) + + response = await client.generate(prompt="Hi", max_tokens=TEST_MAX_TOKENS) + + assert isinstance(response, TextOutput), ( + f"Expected TextOutput, got {type(response)}" + ) + # Empty/None content is valid for reasoning models that use all tokens for thinking + if not response.content: + assert response.finish_reason is not None, ( + f"Model {model.provider.value}/{model.id} returned empty content without finish_reason" + ) + assert isinstance(response.usage, TextUsage), ( + f"Expected TextUsage, got {type(response.usage)}" + ) + if response.usage.output_tokens is not None: + assert response.usage.output_tokens <= TEST_MAX_TOKENS, ( + f"Model {model.provider.value}/{model.id} exceeded max_tokens: {response.usage.output_tokens} > {TEST_MAX_TOKENS}" + ) + + +@pytest.mark.integration +def test_sync_generate() -> None: + """Test sync wrapper works correctly. + + Single model smoke test - sync is just async_to_sync wrapper. + """ + models = list_models(modality=Modality.TEXT, operation=Operation.GENERATE) + model = models[0] + + client = create_client( + modality=Modality.TEXT, + model=model, + ) + + response = client.sync.generate(prompt="Hi", max_tokens=TEST_MAX_TOKENS) + + assert isinstance(response, TextOutput) + assert response.content or response.finish_reason is not None diff --git a/tests/integration_tests/text/test_stream_analyze_audio.py b/tests/integration_tests/text/test_stream_analyze_audio.py new file mode 100644 index 00000000..96d2cb7b --- /dev/null +++ b/tests/integration_tests/text/test_stream_analyze_audio.py @@ -0,0 +1,101 @@ +"""Integration tests for streaming audio analysis - all audio-capable models.""" + +import warnings + +# Suppress deprecation warnings from legacy capability packages +warnings.filterwarnings( + "ignore", + message=".*capability parameter is deprecated.*", + category=DeprecationWarning, +) + +import pytest # noqa: E402 + +from celeste import ( # noqa: E402 + Modality, + Model, + Operation, + create_client, + list_models, +) +from celeste.artifacts import AudioArtifact # noqa: E402 +from celeste.core import InputType # noqa: E402 +from celeste.modalities.text import TextChunk, TextUsage # noqa: E402 + + +@pytest.mark.parametrize( + "model", + [ + m + for m in list_models(modality=Modality.TEXT, operation=Operation.ANALYZE) + if m.streaming and InputType.AUDIO in m.optional_input_types + ], + ids=lambda m: f"{m.provider.value}-{m.id}", +) +@pytest.mark.integration +@pytest.mark.asyncio +async def test_stream_analyze_audio(model: Model, test_audio: AudioArtifact) -> None: + """Test streaming audio analysis for all streaming audio-capable models. + + Dynamically discovers all streaming audio models and verifies each can + stream audio analysis. Failures indicate providers needing audio implementation. + """ + client = create_client( + modality=Modality.TEXT, + model=model, + ) + + chunks: list[TextChunk] = [] + async for chunk in client.stream.analyze( + prompt="Describe what you hear in this audio", + audio=test_audio, + ): + chunks.append(chunk) + + # Assert - empty stream is valid for reasoning models that use all tokens for thinking + if not chunks: + return + + # Assert - received chunks are valid + assert all(isinstance(c, TextChunk) for c in chunks), ( + "All chunks should be TextChunk" + ) + # Assert - content accumulated + content = "".join(c.content or "" for c in chunks) + # Empty/None content is valid for reasoning models that use all tokens for thinking + if not content: + usage_chunks = [c for c in chunks if c.usage is not None] + assert usage_chunks, ( + f"Model {model.provider.value}/{model.id} returned empty content without usage" + ) + + # Assert - usage in final chunks (provider-dependent) + usage_chunks = [c for c in chunks if c.usage is not None] + if usage_chunks: + usage = usage_chunks[-1].usage + assert isinstance(usage, TextUsage), f"Expected TextUsage, got {type(usage)}" + + +@pytest.mark.integration +def test_sync_stream_analyze_audio(test_audio: AudioArtifact) -> None: + """Test sync streaming wrapper works correctly. + + Single model smoke test - sync stream iteration bridges async internally. + """ + models = [ + m + for m in list_models(modality=Modality.TEXT, operation=Operation.ANALYZE) + if m.streaming and InputType.AUDIO in m.optional_input_types + ] + model = models[0] + + client = create_client( + modality=Modality.TEXT, + model=model, + ) + + for _chunk in client.sync.stream.analyze( + prompt="Describe what you hear in this audio", + audio=test_audio, + ): + pass # Just exhaust the stream diff --git a/tests/integration_tests/text/test_stream_analyze_image.py b/tests/integration_tests/text/test_stream_analyze_image.py new file mode 100644 index 00000000..ee90d4cf --- /dev/null +++ b/tests/integration_tests/text/test_stream_analyze_image.py @@ -0,0 +1,109 @@ +"""Integration tests for streaming image analysis - all vision-capable models.""" + +import warnings + +# Suppress deprecation warnings from legacy capability packages +warnings.filterwarnings( + "ignore", + message=".*capability parameter is deprecated.*", + category=DeprecationWarning, +) + +import pytest # noqa: E402 + +from celeste import ( # noqa: E402 + Modality, + Model, + Operation, + create_client, + list_models, +) +from celeste.artifacts import ImageArtifact # noqa: E402 +from celeste.core import InputType # noqa: E402 +from celeste.modalities.text import TextChunk, TextUsage # noqa: E402 + +TEST_MAX_TOKENS = 200 + + +@pytest.mark.parametrize( + "model", + [ + m + for m in list_models(modality=Modality.TEXT, operation=Operation.ANALYZE) + if m.streaming and InputType.IMAGE in m.optional_input_types + ], + ids=lambda m: f"{m.provider.value}-{m.id}", +) +@pytest.mark.integration +@pytest.mark.asyncio +async def test_stream_analyze(model: Model, square_image: ImageArtifact) -> None: + """Test streaming image analysis for all streaming vision-capable models. + + Dynamically discovers all streaming vision models and verifies each can + stream analysis. Failures indicate deprecated or misconfigured models. + """ + client = create_client( + modality=Modality.TEXT, + model=model, + ) + + chunks: list[TextChunk] = [] + async for chunk in client.stream.analyze( + prompt="What is this?", + image=square_image, + max_tokens=TEST_MAX_TOKENS, + ): + chunks.append(chunk) + + # Assert - empty stream is valid for reasoning models that use all tokens for thinking + if not chunks: + return + + # Assert - received chunks are valid + assert all(isinstance(c, TextChunk) for c in chunks), ( + "All chunks should be TextChunk" + ) + # Assert - content accumulated + content = "".join(c.content or "" for c in chunks) + # Empty/None content is valid for reasoning models that use all tokens for thinking + if not content: + usage_chunks = [c for c in chunks if c.usage is not None] + assert usage_chunks, ( + f"Model {model.provider.value}/{model.id} returned empty content without usage" + ) + + # Assert - usage in final chunks (provider-dependent) + usage_chunks = [c for c in chunks if c.usage is not None] + if usage_chunks: + usage = usage_chunks[-1].usage + assert isinstance(usage, TextUsage), f"Expected TextUsage, got {type(usage)}" + if usage.output_tokens is not None: + assert usage.output_tokens <= TEST_MAX_TOKENS, ( + f"Model {model.provider.value}/{model.id} exceeded max_tokens: {usage.output_tokens} > {TEST_MAX_TOKENS}" + ) + + +@pytest.mark.integration +def test_sync_stream_analyze(square_image: ImageArtifact) -> None: + """Test sync streaming wrapper works correctly. + + Single model smoke test - sync stream iteration bridges async internally. + """ + models = [ + m + for m in list_models(modality=Modality.TEXT, operation=Operation.ANALYZE) + if m.streaming and InputType.IMAGE in m.optional_input_types + ] + model = models[0] + + client = create_client( + modality=Modality.TEXT, + model=model, + ) + + for _chunk in client.sync.stream.analyze( + prompt="What is this?", + image=square_image, + max_tokens=TEST_MAX_TOKENS, + ): + pass # Just exhaust the stream diff --git a/tests/integration_tests/text/test_stream_analyze_video.py b/tests/integration_tests/text/test_stream_analyze_video.py new file mode 100644 index 00000000..9b1f7c43 --- /dev/null +++ b/tests/integration_tests/text/test_stream_analyze_video.py @@ -0,0 +1,101 @@ +"""Integration tests for streaming video analysis - all vision-capable models.""" + +import warnings + +# Suppress deprecation warnings from legacy capability packages +warnings.filterwarnings( + "ignore", + message=".*capability parameter is deprecated.*", + category=DeprecationWarning, +) + +import pytest # noqa: E402 + +from celeste import ( # noqa: E402 + Modality, + Model, + Operation, + create_client, + list_models, +) +from celeste.artifacts import VideoArtifact # noqa: E402 +from celeste.core import InputType # noqa: E402 +from celeste.modalities.text import TextChunk, TextUsage # noqa: E402 + + +@pytest.mark.parametrize( + "model", + [ + m + for m in list_models(modality=Modality.TEXT, operation=Operation.ANALYZE) + if m.streaming and InputType.VIDEO in m.optional_input_types + ], + ids=lambda m: f"{m.provider.value}-{m.id}", +) +@pytest.mark.integration +@pytest.mark.asyncio +async def test_stream_analyze_video(model: Model, test_video: VideoArtifact) -> None: + """Test streaming video analysis for all streaming vision-capable models. + + Dynamically discovers all streaming vision models and verifies each can + stream video analysis. Failures indicate providers needing video implementation. + """ + client = create_client( + modality=Modality.TEXT, + model=model, + ) + + chunks: list[TextChunk] = [] + async for chunk in client.stream.analyze( + prompt="Describe what happens in this video", + video=test_video, + ): + chunks.append(chunk) + + # Assert - empty stream is valid for reasoning models that use all tokens for thinking + if not chunks: + return + + # Assert - received chunks are valid + assert all(isinstance(c, TextChunk) for c in chunks), ( + "All chunks should be TextChunk" + ) + # Assert - content accumulated + content = "".join(c.content or "" for c in chunks) + # Empty/None content is valid for reasoning models that use all tokens for thinking + if not content: + usage_chunks = [c for c in chunks if c.usage is not None] + assert usage_chunks, ( + f"Model {model.provider.value}/{model.id} returned empty content without usage" + ) + + # Assert - usage in final chunks (provider-dependent) + usage_chunks = [c for c in chunks if c.usage is not None] + if usage_chunks: + usage = usage_chunks[-1].usage + assert isinstance(usage, TextUsage), f"Expected TextUsage, got {type(usage)}" + + +@pytest.mark.integration +def test_sync_stream_analyze_video(test_video: VideoArtifact) -> None: + """Test sync streaming wrapper works correctly. + + Single model smoke test - sync stream iteration bridges async internally. + """ + models = [ + m + for m in list_models(modality=Modality.TEXT, operation=Operation.ANALYZE) + if m.streaming and InputType.VIDEO in m.optional_input_types + ] + model = models[0] + + client = create_client( + modality=Modality.TEXT, + model=model, + ) + + for _chunk in client.sync.stream.analyze( + prompt="Describe what happens in this video", + video=test_video, + ): + pass # Just exhaust the stream diff --git a/tests/integration_tests/text/test_stream_generate.py b/tests/integration_tests/text/test_stream_generate.py new file mode 100644 index 00000000..f1259e37 --- /dev/null +++ b/tests/integration_tests/text/test_stream_generate.py @@ -0,0 +1,92 @@ +"""Integration tests for streaming text generation - all registered models.""" + +import warnings + +# Suppress deprecation warnings from legacy capability packages +warnings.filterwarnings( + "ignore", + message=".*capability parameter is deprecated.*", + category=DeprecationWarning, +) + +import pytest # noqa: E402 + +from celeste import ( # noqa: E402 + Modality, + Model, + Operation, + create_client, + list_models, +) +from celeste.modalities.text import TextChunk, TextUsage # noqa: E402 + +TEST_MAX_TOKENS = 200 + + +@pytest.mark.parametrize( + "model", + [ + m + for m in list_models(modality=Modality.TEXT, operation=Operation.GENERATE) + if m.streaming + and not m.optional_input_types # Media-capable models tested in test_stream_analyze_* + ], + ids=lambda m: f"{m.provider.value}-{m.id}", +) +@pytest.mark.integration +@pytest.mark.asyncio +async def test_stream_generate(model: Model) -> None: + """Test streaming text generation for all streaming-capable models. + + Dynamically discovers all streaming models and verifies each can stream text. + Failures indicate deprecated or misconfigured models. + """ + client = create_client( + modality=Modality.TEXT, + model=model, + ) + + chunks: list[TextChunk] = [] + async for chunk in client.stream.generate(prompt="Hi", max_tokens=TEST_MAX_TOKENS): + chunks.append(chunk) + + # Assert - empty stream is valid for reasoning models that use all tokens for thinking + if not chunks: + return + + # Assert - received chunks are valid + assert all(isinstance(c, TextChunk) for c in chunks), ( + "All chunks should be TextChunk" + ) + + # Assert - usage in final chunks (provider-dependent) + usage_chunks = [c for c in chunks if c.usage is not None] + if usage_chunks: + usage = usage_chunks[-1].usage + assert isinstance(usage, TextUsage), f"Expected TextUsage, got {type(usage)}" + if usage.output_tokens is not None: + assert usage.output_tokens <= TEST_MAX_TOKENS, ( + f"Model {model.provider.value}/{model.id} exceeded max_tokens: {usage.output_tokens} > {TEST_MAX_TOKENS}" + ) + + +@pytest.mark.integration +def test_sync_stream_generate() -> None: + """Test sync streaming wrapper works correctly. + + Single model smoke test - sync stream iteration bridges async internally. + """ + models = [ + m + for m in list_models(modality=Modality.TEXT, operation=Operation.GENERATE) + if m.streaming and not m.optional_input_types + ] + model = models[0] + + client = create_client( + modality=Modality.TEXT, + model=model, + ) + + for _chunk in client.sync.stream.generate(prompt="Hi", max_tokens=TEST_MAX_TOKENS): + pass # Just exhaust the stream diff --git a/tests/integration_tests/videos/__init__.py b/tests/integration_tests/videos/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration_tests/videos/test_generate.py b/tests/integration_tests/videos/test_generate.py new file mode 100644 index 00000000..1e7edc7e --- /dev/null +++ b/tests/integration_tests/videos/test_generate.py @@ -0,0 +1,90 @@ +"""Integration tests for videos generate operation across all providers.""" + +import warnings + +# Suppress deprecation warnings from legacy capability packages +warnings.filterwarnings( + "ignore", + message=".*capability parameter is deprecated.*", + category=DeprecationWarning, +) + +import pytest # noqa: E402 + +from celeste import Modality, Provider, create_client # noqa: E402 +from celeste.artifacts import VideoArtifact # noqa: E402 +from celeste.modalities.videos import VideoOutput, VideoUsage # noqa: E402 + + +@pytest.mark.parametrize( + ("provider", "model", "parameters"), + [ + # OpenAI Sora: min duration 4s, only 720p available + (Provider.OPENAI, "sora-2", {"duration": 4, "resolution": "720p"}), + # Google Veo: duration options [4, 6, 8], 720p available + (Provider.GOOGLE, "veo-3.0-fast-generate-001", {"duration": 4}), + # BytePlus Seedance: min duration 2s, 480p is cheapest + ( + Provider.BYTEPLUS, + "seedance-1-0-lite-t2v-250428", + {"duration": 2, "resolution": "480p"}, + ), + ], +) +@pytest.mark.integration +@pytest.mark.slow +@pytest.mark.asyncio +async def test_generate(provider: Provider, model: str, parameters: dict) -> None: + """Test video generation across providers. + + Uses cheapest/fastest model per provider with minimum duration/resolution + to minimize costs. Marked as slow since video generation takes 30-120+ seconds. + """ + client = create_client( + modality=Modality.VIDEOS, + provider=provider, + model=model, + ) + + response = await client.generate( + prompt="A cat walking on the beach", + **parameters, + ) + + assert isinstance(response, VideoOutput), ( + f"Expected VideoOutput, got {type(response)}" + ) + assert isinstance(response.content, VideoArtifact), ( + f"Expected VideoArtifact content, got {type(response.content)}" + ) + assert response.content.has_content, ( + f"VideoArtifact has no content (url/data/path): {response.content}" + ) + assert isinstance(response.usage, VideoUsage), ( + f"Expected VideoUsage, got {type(response.usage)}" + ) + + +@pytest.mark.integration +@pytest.mark.slow +def test_sync_generate() -> None: + """Test sync wrapper works correctly. + + Single model smoke test - sync is just async_to_sync wrapper. + Uses BytePlus Seedance with minimum settings (cheapest option). + """ + client = create_client( + modality=Modality.VIDEOS, + provider=Provider.BYTEPLUS, + model="seedance-1-0-lite-t2v-250428", + ) + + response = client.sync.generate( + prompt="A ball rolling", + duration=2, + resolution="480p", + ) + + assert isinstance(response, VideoOutput) + assert isinstance(response.content, VideoArtifact) + assert response.content.has_content diff --git a/tests/testing_guidelines.md b/tests/testing_guidelines.md new file mode 100644 index 00000000..6ed24fed --- /dev/null +++ b/tests/testing_guidelines.md @@ -0,0 +1,261 @@ +# Python Unit Testing Best Practices + +This guide is a practical, drop‑in reference for writing, organizing, and running tests in modern Python projects. + +--- + +## 0) Scope & prerequisites + +- **Test runner**: `pytest` 8.x +- **Python**: **≥ 3.12** +- **Layout**: prefer the **`src/` layout** with tests in a top‑level `tests/` directory. +- **Config home**: configure `pytest` via **`pyproject.toml`** under `[tool.pytest.ini_options]` (recommended). + +--- + +## 1) Project layout & discovery + +**Recommended structure** +``` +pyproject.toml +src/ + yourpkg/ + __init__.py + core.py +tests/ + test_core.py + conftest.py +``` + +**Discovery rules (defaults)** +- Test files: `test_*.py` or `*_test.py` +- Test classes: `Test*` (no `__init__`) +- Test functions: `test_*` +- Can be customized with `python_files`, `python_classes`, `python_functions` + +**Minimal `pyproject.toml`** +```toml +[tool.pytest.ini_options] +minversion = "8.0" +testpaths = ["tests"] +addopts = "-ra --strict-markers --strict-config" +markers = [ + "slow: marks tests as slow (deselect with '-m "not slow"')", + "smoke: quick checks for critical paths", +] +``` + +> Use `--strict-markers` to catch typos in marker names and `--strict-config` to fail on unknown config keys. + +--- + +## 2) Writing clean, maintainable tests + +- Prefer **AAA** (Arrange–Act–Assert) and **expressive names**, e.g. `test_user_cannot_login_with_invalid_password`. +- **Fixtures over xUnit `setUp/tearDown`**: pytest fixtures are explicit, composable, and scoping is clear (`function`, `class`, `module`, `session`). +- Prefer **pure functions** and **dependency injection** to minimize mocking. +- Keep one logical assertion per test; if multiple, make them about the same behavior. + +**Handy built‑in fixtures** +- `tmp_path` / `tmp_path_factory`: isolated temporary paths (Pathlib) +- `monkeypatch`: patch environment variables, attributes, or `sys.path` +- `capsys` / `capfd`: capture stdout/stderr +- `caplog`: capture and assert on log records + +**Examples** +```python +# tests/test_files.py +def test_writes_report(tmp_path): + out = tmp_path / "report.txt" + out.write_text("ok") + assert out.read_text() == "ok" + +# tests/test_env.py +def test_uses_api_key(monkeypatch): + monkeypatch.setenv("API_KEY", "fake-key") + assert load_api_key() == "fake-key" +``` + +--- + +## 3) Parametrization + +Use `@pytest.mark.parametrize` to cover many cases succinctly. + +```python +import pytest +from yourpkg.core import add + +@pytest.mark.parametrize("a,b,out", [(1,2,3),(0,0,0),(-1,1,0)]) +def test_add(a, b, out): + assert add(a, b) == out +``` + +--- + +## 4) Mocking you can trust + +- Use the stdlib `unittest.mock` with **`autospec`** / **`spec_set=True`** to ensure mocks match real call signatures/attributes. +- **Patch where it’s used**, not where it’s defined (patch the import path the SUT references). +- Prefer fakes over mocks when behavior is simple. + +```python +from unittest.mock import patch + +def test_sends_email(): + with patch("yourpkg.mailer.smtp", autospec=True) as mock_smtp: + send_welcome_email("alice@example.com") + mock_smtp.SMTP.assert_called_once() +``` + +--- + +## 5) Async & concurrency + +- Use **`pytest-asyncio`** for `async def` tests (configure loop scope if needed). +- Avoid real sleeps; await tasks and set timeouts. +- Ensure cleanup of tasks to avoid event‑loop leakage. +- Be careful when combining async tests with parallelization (xdist); isolate shared state. + +```python +import pytest + +async def test_async_flow(): + assert await do_async() == "ok" +``` + +--- + +## 6) Determinism: random, time, order + +- Shuffle test order locally with a random-order plugin if you choose to add one back. +- Freeze time for clock‑sensitive logic with **Freezegun** or **pytest-freezer**. +- When asserting on logs, set levels explicitly: `caplog.set_level(\"INFO\")`. + +--- + +## 7) Speed & reliability (flake‑resistant) + +- **Parallelize** locally and in CI with **`pytest-xdist`**: `pytest -n auto`. +- Separate **fast smoke tests** from **slow** ones with markers; run `-m "not slow"` in tight loops. +- Avoid real network/disk where possible: + - Use **`responses`** for `requests` + - Use **`respx`** for `httpx` +- Keep fixtures small and scoped; prefer function scope by default. + +--- + +## 8) Coverage you can act on + +- Use **coverage.py** (via **`pytest-cov`**) with **branch coverage**. +- Enable **coverage contexts** to answer “_which test covered this line?_”. + +**Coverage config (pyproject) and usage:** +```toml +[tool.coverage.run] +branch = true +dynamic_context = "test_function" + +[tool.coverage.report] +show_missing = true +fail_under = 90 +``` +```bash +pytest --cov=yourpkg --cov-report=term-missing --cov-report=html --cov-context=test +# Open htmlcov/index.html to review per-test contexts +``` + +--- + +## 9) Property‑based testing (PBT) + +Use **Hypothesis** to generate many inputs and shrink failures automatically. + +```python +from hypothesis import given, strategies as st + +@given(st.text()) +def test_round_trip(s): + assert decode(encode(s)) == s +``` + +Great for parsers, arithmetic, serialization, and stateful workflows. + +--- + +## 10) Frequently useful pytest flags + +- `-q -ra` — quiet + extra summary of fails/skips +- `-k EXPRESSION` — filter by test name expression +- `-m MARKEXPR` — run by marker (e.g., `-m "smoke and not slow"`) +- `--maxfail=1` — fail fast +- `--pdb -x` — drop into debugger on first failure +- `--ff` — run failures first (requires `pytest`'s cache) + +--- + +## 11) Example repository skeleton + +See Section 1 for directory layout. + +**`tests/test_core.py`** +```python +import pytest +from yourpkg.core import add + +@pytest.mark.smoke +@pytest.mark.parametrize("a,b,out", [(1,2,3),(0,0,0),(-1,1,0)]) +def test_add(a, b, out): + assert add(a, b) == out +``` + +--- + +## 12) CI quickstart (GitHub Actions) + +```yaml +name: tests +on: [push, pull_request] +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: | + pip install -e . + pip install pytest pytest-cov pytest-asyncio pytest-xdist hypothesis + pytest -n auto -q --cov=yourpkg --cov-report=term-missing --cov-context=test +``` + +--- + +## 13) Quick checklist + +- [ ] `src/` layout with tests in `tests/` +- [ ] `pyproject.toml` has `--strict-markers` and `--strict-config` +- [ ] Slow tests are marked and isolated +- [ ] Parallel runs are clean (`pytest -n auto`) +- [ ] Coverage: branch + contexts, threshold set +- [ ] Deterministic RNG/time; flaky sources stubbed +- [ ] Async tests use `pytest-asyncio` +- [ ] Property‑based tests for critical invariants +- [ ] CI runs matrix across supported Python versions + +--- + +## 14) Troubleshooting tips + +- **Import errors disappear locally, fail in CI** → ensure `src/` layout and install the package in editable mode (`pip install -e .`) before running tests. +- **Mock doesn’t behave like real object** → use `autospec`/`spec_set` and patch the import location used by the SUT. +- **Intermittent failures** → seed randomness, freeze time, and run with `-n auto --dist loadfile` to surface shared‑state issues. +- **“Which test hit this line?”** → enable coverage contexts (`--cov-context=test`) and inspect `htmlcov` (toggle “Show contexts”). + +--- + +Happy testing! 🧪 diff --git a/tests/unit_tests/__init__.py b/tests/unit_tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/test_artifacts.py b/tests/unit_tests/test_artifacts.py index 17cebeca..d5d22cf2 100644 --- a/tests/unit_tests/test_artifacts.py +++ b/tests/unit_tests/test_artifacts.py @@ -1,5 +1,6 @@ """High-value tests for artifact classes - focusing on real-world usage patterns.""" +from pathlib import Path from typing import Any import pytest @@ -207,3 +208,88 @@ def test_audio_artifact_rejects_invalid_mime_type( url="https://example.com/audio.mp3", mime_type=invalid_mime_type, # type: ignore[arg-type] ) + + +class TestArtifactGetBytes: + """Test get_bytes() method.""" + + def test_get_bytes_from_data(self) -> None: + """Test get_bytes returns data when available.""" + artifact = Artifact(data=b"test binary data") + assert artifact.get_bytes() == b"test binary data" + + def test_get_bytes_from_path(self, tmp_path: Path) -> None: + """Test get_bytes reads from file path.""" + test_file = tmp_path / "test.bin" + test_file.write_bytes(b"file content") + + artifact = Artifact(path=str(test_file)) + assert artifact.get_bytes() == b"file content" + + def test_get_bytes_prefers_data_over_path(self, tmp_path: Path) -> None: + """Test get_bytes prefers data when both data and path are set.""" + test_file = tmp_path / "test.bin" + test_file.write_bytes(b"file content") + + artifact = Artifact(data=b"in-memory data", path=str(test_file)) + assert artifact.get_bytes() == b"in-memory data" + + def test_get_bytes_raises_without_data_or_path(self) -> None: + """Test get_bytes raises ValueError when no data or path.""" + artifact = Artifact(url="https://example.com/file") + with pytest.raises(ValueError, match="Artifact must have data or path"): + artifact.get_bytes() + + def test_get_bytes_raises_for_empty_artifact(self) -> None: + """Test get_bytes raises ValueError for empty artifact.""" + artifact = Artifact() + with pytest.raises(ValueError, match="Artifact must have data or path"): + artifact.get_bytes() + + +class TestArtifactGetBase64: + """Test get_base64() method.""" + + def test_get_base64_from_data(self) -> None: + """Test get_base64 encodes data correctly.""" + artifact = Artifact(data=b"test binary data") + result = artifact.get_base64() + import base64 + + assert base64.b64decode(result) == b"test binary data" + + def test_get_base64_from_path(self, tmp_path: Path) -> None: + """Test get_base64 reads and encodes file.""" + test_file = tmp_path / "test.bin" + test_file.write_bytes(b"file content") + + artifact = Artifact(path=str(test_file)) + result = artifact.get_base64() + import base64 + + assert base64.b64decode(result) == b"file content" + + +class TestArtifactSerialization: + """Test artifact serialization behavior.""" + + def test_artifact_serialize_data_with_none(self) -> None: + """Test that serialize_data handles None value correctly.""" + artifact = Artifact() + # serialize_data is called during JSON serialization when data is None + # This tests the field_serializer behavior + json_data = artifact.model_dump(mode="json") + assert json_data.get("data") is None + + def test_artifact_serialize_data_with_bytes(self) -> None: + """Test that serialize_data converts bytes to base64 string.""" + artifact = Artifact(data=b"test binary data") + # serialize_data is called during JSON serialization + json_data = artifact.model_dump(mode="json") + assert json_data.get("data") is not None + assert isinstance(json_data["data"], str) + # Verify it's valid base64 + import base64 + + decoded = base64.b64decode(json_data["data"]) + assert decoded == b"test binary data" diff --git a/tests/unit_tests/test_auth.py b/tests/unit_tests/test_auth.py new file mode 100644 index 00000000..3212e723 --- /dev/null +++ b/tests/unit_tests/test_auth.py @@ -0,0 +1,102 @@ +"""Tests for authentication classes and registry.""" + +from collections.abc import Generator + +import pytest +from pydantic import SecretStr + +from celeste.auth import ( + APIKey, + Authentication, + AuthHeader, + get_auth_class, + register_auth, +) + + +@pytest.fixture(autouse=True) +def clean_auth_registry() -> Generator[None, None, None]: + """Clear auth registry before and after each test to ensure isolation. + + Tests must not depend on provider package import side-effects. + """ + import importlib + + auth_module = importlib.import_module("celeste.auth") + + # Store original state + original_auth_classes = auth_module._auth_classes.copy() + + # Clear before test + auth_module._auth_classes.clear() + + yield + + # Clear after test + auth_module._auth_classes.clear() + # Restore original state (in case tests registered something) + auth_module._auth_classes.update(original_auth_classes) + + +class TestAuthHeader: + """Test AuthHeader authentication class.""" + + def test_convert_to_secret_with_string(self) -> None: + """Test that convert_to_secret converts plain string to SecretStr.""" + auth = AuthHeader(secret="test-api-key") # type: ignore[arg-type] # nosec B106 + assert isinstance(auth.secret, type(auth.secret)) + assert auth.secret.get_secret_value() == "test-api-key" + + def test_convert_to_secret_with_secretstr(self) -> None: + """Test that convert_to_secret accepts SecretStr directly.""" + from pydantic import SecretStr + + secret = SecretStr("existing-secret") + auth = AuthHeader(secret=secret) + assert auth.secret is secret + + def test_get_headers_returns_correct_format(self) -> None: + """Test that get_headers returns header dict with prefix.""" + auth = AuthHeader( + secret=SecretStr("test-key"), header="Authorization", prefix="Bearer " + ) + headers = auth.get_headers() + assert headers == {"Authorization": "Bearer test-key"} + + def test_get_headers_with_custom_header_and_prefix(self) -> None: + """Test that get_headers works with custom header name and prefix.""" + auth = AuthHeader( + secret=SecretStr("api-key-123"), header="x-api-key", prefix="" + ) + headers = auth.get_headers() + assert headers == {"x-api-key": "api-key-123"} + + +class TestAPIAlias: + """Test APIKey alias for AuthHeader.""" + + def test_apikey_is_alias_for_authheader(self) -> None: + """Test that APIKey is an alias for AuthHeader.""" + assert APIKey is AuthHeader + + +class TestAuthRegistry: + """Test authentication class registry.""" + + def test_register_auth_stores_auth_class(self) -> None: + """Test that register_auth stores authentication class in registry.""" + + class CustomAuth(Authentication): + def get_headers(self) -> dict[str, str]: + return {"Custom-Header": "value"} + + register_auth("custom", CustomAuth) + retrieved = get_auth_class("custom") + assert retrieved is CustomAuth + + def test_get_auth_class_with_unknown_type_raises(self) -> None: + """Test that get_auth_class raises ValueError for unknown auth type.""" + with pytest.raises( + ValueError, match=r"Unknown auth type: nonexistent.*Available:" + ): + get_auth_class("nonexistent") diff --git a/tests/unit_tests/test_client.py b/tests/unit_tests/test_client.py index 7bc24905..a98fe87f 100644 --- a/tests/unit_tests/test_client.py +++ b/tests/unit_tests/test_client.py @@ -1,26 +1,21 @@ -"""High-value tests for Client - focusing on critical validation and framework behavior.""" +"""High-value tests for ModalityClient - focusing on request building and framework behavior.""" -from collections.abc import AsyncIterator, Generator +from collections.abc import AsyncIterator from enum import StrEnum from typing import Any, Unpack -import httpx import pytest from pydantic import SecretStr from celeste.auth import APIKey -from celeste.client import Client, _clients, get_client_class, register_client -from celeste.core import Capability, Provider -from celeste.exceptions import ( - ClientNotFoundError, - StreamingNotSupportedError, - UnsupportedCapabilityError, -) +from celeste.client import ModalityClient +from celeste.core import Modality, Provider +from celeste.exceptions import StreamingNotSupportedError from celeste.io import Chunk, Input, Output, Usage -from celeste.models import Model +from celeste.models import Model, Operation from celeste.parameters import ParameterMapper, Parameters from celeste.streaming import Stream -from celeste.types import StructuredOutput +from celeste.types import TextContent class ParamEnum(StrEnum): @@ -37,40 +32,6 @@ class _TestInput(Input): prompt: str -def _create_test_client_class( - generate_output: str = "test output", - class_name: str | None = None, -) -> type[Client]: - """Create a test client class with minimal implementation.""" - if class_name is None: - class_name = f"TestClient_{generate_output.replace(' ', '_')}" - - class TestClientClass(Client): - """Test client implementation.""" - - @classmethod - def parameter_mappers(cls) -> list[ParameterMapper]: - return [] - - def _init_request(self, inputs: Input) -> dict[str, Any]: - prompt = getattr(inputs, "prompt", "test prompt") - return {"prompt": prompt} - - def _parse_usage(self, response_data: dict[str, Any]) -> Usage: - return Usage() - - def _parse_content( # type: ignore[override] - self, response_data: dict[str, Any], **parameters: Unpack[Parameters] - ) -> object: - return response_data.get("content", "test content") - - async def generate(self, **parameters: Unpack[Parameters]) -> Output: - return Output(content=generate_output) - - TestClientClass.__name__ = class_name - return TestClientClass - - def _create_test_mapper( param_name: StrEnum, map_key: str | None = None, @@ -94,8 +55,8 @@ def map( return request def parse_output( - self, content: StructuredOutput, value: object | None - ) -> StructuredOutput: + self, content: TextContent, value: object | None + ) -> TextContent: return content return TestMapperClass() @@ -124,8 +85,8 @@ def map( return request def parse_output( - self, content: StructuredOutput, value: object | None - ) -> StructuredOutput: + self, content: TextContent, value: object | None + ) -> TextContent: if value is not None: return f"{content}_transformed_with_{value}" return content @@ -139,291 +100,60 @@ def text_model() -> Model: return Model( id="gpt-4", provider=Provider.OPENAI, - capabilities={Capability.TEXT_GENERATION}, + operations={Modality.TEXT: {Operation.GENERATE}}, display_name="GPT-4", ) -@pytest.fixture -def multimodal_model() -> Model: - """Model that supports both text and image capabilities.""" - return Model( - id="gpt-4-vision", - provider=Provider.OPENAI, - capabilities={Capability.TEXT_GENERATION, Capability.IMAGE_GENERATION}, - display_name="GPT-4 Vision", - ) - - @pytest.fixture def api_key() -> str: """Test API key.""" return "sk-test123456789" -class ConcreteClient(Client): - """Concrete implementation for testing Client behavior.""" +class ConcreteModalityClient(ModalityClient[_TestInput, Output, Parameters, str]): + """Concrete ModalityClient implementation for testing.""" @classmethod def parameter_mappers(cls) -> list[ParameterMapper]: return [] - def _init_request(self, inputs: Input) -> dict[str, Any]: - prompt = getattr(inputs, "prompt", "test prompt") - return {"prompt": prompt, "model": self.model.id} + def _init_request(self, inputs: _TestInput) -> dict[str, Any]: + return {"prompt": inputs.prompt, "model": self.model.id} def _parse_usage(self, response_data: dict[str, Any]) -> Usage: return Usage() def _parse_content( # type: ignore[override] self, response_data: dict[str, Any], **parameters: Unpack[Parameters] - ) -> object: - return response_data.get("content", "test content") - - def _create_inputs( - self, - *args: Any, # noqa: ANN401 - **parameters: Unpack[Parameters], - ) -> Input: - """Map positional arguments to Input type.""" - if args: - prompt = str(args[0]) - return _TestInput(prompt=prompt) - prompt_value = parameters.get("prompt", "test prompt") - prompt = str(prompt_value) if prompt_value is not None else "test prompt" - return _TestInput(prompt=prompt) + ) -> str: + content = response_data.get("content", "test content") + return content if isinstance(content, str) else "test content" @classmethod def _output_class(cls) -> type[Output]: - """Return the Output class for this client.""" return Output async def _make_request( # type: ignore[override] - self, request_body: dict[str, Any], **parameters: Unpack[Parameters] - ) -> httpx.Response: - """Make HTTP request(s) and return response object.""" - return httpx.Response( - 200, - json={"content": "test content"}, - request=httpx.Request("POST", "https://test.com"), - ) + self, + request_body: dict[str, Any], + *, + endpoint: str | None = None, + **parameters: Unpack[Parameters], + ) -> dict[str, Any]: + return {"content": "test content"} def _stream_class(self) -> type[Stream[Output, Parameters, Chunk]]: - """Return the Stream class for this client.""" raise NotImplementedError("Streaming not implemented in test client") def _make_stream_request( # type: ignore[override] self, request_body: dict[str, Any], **parameters: Unpack[Parameters] ) -> AsyncIterator[dict[str, Any]]: - """Make HTTP streaming request and return async iterator of events.""" raise NotImplementedError("Streaming not implemented in test client") -class TestClientValidation: - """Test Client critical validation behaviors.""" - - @pytest.mark.smoke - def test_successful_creation_with_compatible_capability( - self, text_model: Model, api_key: str - ) -> None: - """Client accepts model that supports the required capability.""" - # Arrange & Act - client = ConcreteClient( - model=text_model, - provider=text_model.provider, - capability=Capability.TEXT_GENERATION, - auth=APIKey(key=SecretStr(api_key)), - ) - - # Assert - assert client.model == text_model - assert client.capability == Capability.TEXT_GENERATION - - def test_validation_failure_with_incompatible_capability( - self, text_model: Model, api_key: str - ) -> None: - """Client rejects model that lacks required capability.""" - # Arrange & Act & Assert - with pytest.raises( - UnsupportedCapabilityError, - match=rf"Model 'gpt-4' does not support capability '{Capability.IMAGE_GENERATION}'", - ): - ConcreteClient( - model=text_model, - provider=text_model.provider, - capability=Capability.IMAGE_GENERATION, # Model doesn't support this - auth=APIKey(key=SecretStr(api_key)), - ) - - @pytest.mark.parametrize( - "capability,description", - [ - (Capability.TEXT_GENERATION, "text capability from multimodal model"), - (Capability.IMAGE_GENERATION, "image capability from multimodal model"), - ], - ids=["text_capability", "image_capability"], - ) - def test_validation_success_with_supported_capabilities( - self, - multimodal_model: Model, - api_key: str, - capability: Capability, - description: str, - ) -> None: - """Client accepts model that supports requested capability.""" - # Arrange & Act - client = ConcreteClient( - model=multimodal_model, - provider=multimodal_model.provider, - capability=capability, - auth=APIKey(key=SecretStr(api_key)), - ) - - # Assert - assert client.model == multimodal_model - assert client.capability == capability - - def test_validation_fails_with_model_lacking_any_capabilities( - self, api_key: str - ) -> None: - """Client rejects models with empty capability set.""" - # Arrange - empty_model = Model( - id="broken-model", - provider=Provider.OPENAI, - capabilities=set(), # No capabilities - display_name="Broken Model", - ) - - # Act & Assert - with pytest.raises( - UnsupportedCapabilityError, - match=rf"Model 'broken-model' does not support capability '{Capability.TEXT_GENERATION}'", - ): - ConcreteClient( - model=empty_model, - provider=empty_model.provider, - capability=Capability.TEXT_GENERATION, - auth=APIKey(key=SecretStr(api_key)), - ) - - -class TestClientRegistry: - """Test client registry functions - register_client and get_client_class.""" - - @pytest.fixture(autouse=True) - def clear_registry(self) -> Generator[None, None, None]: - """Clear the client registry before each test to ensure isolation.""" - # Arrange - Store original state and clear registry - original_clients = _clients.copy() - _clients.clear() - - yield - - # Cleanup - Restore original state - _clients.clear() - _clients.update(original_clients) - - @pytest.mark.smoke - def test_register_and_retrieve_client_success(self) -> None: - """Registry stores and retrieves client classes correctly.""" - # Arrange - capability = Capability.TEXT_GENERATION - provider = Provider.OPENAI - - # Act - register_client(capability, provider, ConcreteClient) - retrieved_class = get_client_class(capability, provider) - - # Assert - assert retrieved_class is ConcreteClient - - def test_get_client_class_raises_for_unregistered_capability(self) -> None: - """get_client_class raises ClientNotFoundError for unregistered capabilities.""" - # Arrange - unregistered_capability = Capability.IMAGE_GENERATION - provider = Provider.OPENAI - - # Act & Assert - with pytest.raises( - ClientNotFoundError, - match=rf"No client registered for {Capability.IMAGE_GENERATION}", - ): - get_client_class(unregistered_capability, provider) - - def test_register_client_overwrites_previous_registration(self) -> None: - """Registering a new client for existing capability overwrites the previous one.""" - # Arrange - capability = Capability.TEXT_GENERATION - provider = Provider.OPENAI - - FirstClient = _create_test_client_class("first client", "FirstClient") - SecondClient = _create_test_client_class("second client", "SecondClient") - - # Act - register_client(capability, provider, FirstClient) - register_client(capability, provider, SecondClient) # Overwrite - retrieved_class = get_client_class(capability, provider) - - # Assert - assert retrieved_class is SecondClient - - def test_registry_isolation_between_different_capabilities(self) -> None: - """Different capabilities stored independently in the registry.""" - # Arrange - text_capability = Capability.TEXT_GENERATION - image_capability = Capability.IMAGE_GENERATION - provider = Provider.OPENAI - - TextClient = _create_test_client_class("text output", "TextClient") - ImageClient = _create_test_client_class("image output", "ImageClient") - - # Act - register_client(text_capability, provider, TextClient) - register_client(image_capability, provider, ImageClient) - - # Assert - assert get_client_class(text_capability, provider) is TextClient - assert get_client_class(image_capability, provider) is ImageClient - - @pytest.mark.parametrize( - "missing_capability,provider,expected_capability_str,expected_provider_str", - [ - ( - Capability.IMAGE_GENERATION, - Provider.ANTHROPIC, - "image-generation", - "anthropic", - ), - ( - Capability.VIDEO_GENERATION, - Provider.OPENAI, - "video-generation", - "openai", - ), - ], - ids=["image_anthropic", "video_openai"], - ) - def test_exception_message_includes_capability_and_provider( - self, - missing_capability: Capability, - provider: Provider, - expected_capability_str: str, - expected_provider_str: str, - ) -> None: - """ClientNotFoundError includes both capability and provider for debugging.""" - # Arrange & Act & Assert - with pytest.raises(ClientNotFoundError) as exc_info: - get_client_class(missing_capability, provider) - - # Assert both parts in error message - error_msg = str(exc_info.value) - assert expected_capability_str in error_msg - assert expected_provider_str in error_msg - - -class TestClientRequestBuilding: - """Test Client._build_request parameter mapping logic.""" +class TestModalityClientRequestBuilding: + """Test ModalityClient._build_request parameter mapping logic.""" @pytest.mark.smoke def test_build_request_applies_parameter_mappers_correctly( @@ -432,19 +162,18 @@ def test_build_request_applies_parameter_mappers_correctly( """_build_request applies all parameter mappers in sequence.""" # Arrange - class ClientWithMapper(ConcreteClient): + class ClientWithMapper(ConcreteModalityClient): """Client with custom parameter mapper.""" @classmethod def parameter_mappers(cls) -> list[ParameterMapper]: - """Return test mapper.""" return [_create_test_mapper(ParamEnum.TEST_PARAM)] client = ClientWithMapper( + modality=Modality.TEXT, model=text_model, provider=text_model.provider, - capability=Capability.TEXT_GENERATION, - auth=APIKey(key=SecretStr(api_key)), + auth=APIKey(secret=SecretStr(api_key)), ) inputs = _TestInput(prompt="test prompt") @@ -462,22 +191,21 @@ def test_build_request_with_multiple_mappers( """_build_request applies multiple parameter mappers in order.""" # Arrange - class ClientWithMultipleMappers(ConcreteClient): + class ClientWithMultipleMappers(ConcreteModalityClient): """Client with multiple parameter mappers.""" @classmethod def parameter_mappers(cls) -> list[ParameterMapper]: - """Return multiple test mappers.""" return [ _create_test_mapper(ParamEnum.FIRST_PARAM), _create_test_mapper(ParamEnum.SECOND_PARAM), ] client = ClientWithMultipleMappers( + modality=Modality.TEXT, model=text_model, provider=text_model.provider, - capability=Capability.TEXT_GENERATION, - auth=APIKey(key=SecretStr(api_key)), + auth=APIKey(secret=SecretStr(api_key)), ) inputs = _TestInput(prompt="test prompt") @@ -509,19 +237,18 @@ def test_transform_output_applies_mappers( """_transform_output applies parameter mapper output transformations.""" # Arrange - class ClientWithTransformMapper(ConcreteClient): + class ClientWithTransformMapper(ConcreteModalityClient): """Client with output transformation mapper.""" @classmethod def parameter_mappers(cls) -> list[ParameterMapper]: - """Return transform mapper.""" return [_create_transform_mapper(ParamEnum.TEST_PARAM)] client = ClientWithTransformMapper( + modality=Modality.TEXT, model=text_model, provider=text_model.provider, - capability=Capability.TEXT_GENERATION, - auth=APIKey(key=SecretStr(api_key)), + auth=APIKey(secret=SecretStr(api_key)), ) original_content = "original content" @@ -534,26 +261,37 @@ def parameter_mappers(cls) -> list[ParameterMapper]: assert transformed == expected_output -class TestClientStreaming: - """Test Client.stream default behavior.""" +class TestModalityClientStreaming: + """Test ModalityClient._stream default behavior.""" - def test_stream_raises_not_implemented_with_descriptive_error( - self, text_model: Model, api_key: str + def test_stream_raises_not_supported_for_non_streaming_model( + self, api_key: str ) -> None: - """stream() raises StreamingNotSupportedError with capability and provider info.""" + """_stream raises StreamingNotSupportedError when model doesn't support streaming.""" # Arrange - client = ConcreteClient( - model=text_model, - provider=text_model.provider, - capability=Capability.TEXT_GENERATION, - auth=APIKey(key=SecretStr(api_key)), + non_streaming_model = Model( + id="non-streaming-model", + provider=Provider.OPENAI, + operations={Modality.TEXT: {Operation.GENERATE}}, + display_name="Non-Streaming Model", + streaming=False, # Streaming disabled + ) + + client = ConcreteModalityClient( + modality=Modality.TEXT, + model=non_streaming_model, + provider=non_streaming_model.provider, + auth=APIKey(secret=SecretStr(api_key)), ) # Act & Assert with pytest.raises(StreamingNotSupportedError) as exc_info: - client.stream("test prompt") + client._stream( + _TestInput(prompt="test"), + stream_class=Stream, # type: ignore + ) - # Verify error message contains all debugging info + # Verify error message error_msg = str(exc_info.value) assert "Streaming not supported" in error_msg - assert "gpt-4" in error_msg + assert "non-streaming-model" in error_msg diff --git a/tests/unit_tests/test_constraints.py b/tests/unit_tests/test_constraints.py index 0748a106..42abd180 100644 --- a/tests/unit_tests/test_constraints.py +++ b/tests/unit_tests/test_constraints.py @@ -3,8 +3,10 @@ import pytest from pydantic import BaseModel -from celeste.artifacts import ImageArtifact +from celeste.artifacts import AudioArtifact, ImageArtifact, VideoArtifact from celeste.constraints import ( + AudioConstraint, + AudiosConstraint, Bool, Choice, Float, @@ -15,9 +17,11 @@ Range, Schema, Str, + VideoConstraint, + VideosConstraint, ) from celeste.exceptions import ConstraintViolationError -from celeste.mime_types import ImageMimeType +from celeste.mime_types import AudioMimeType, ImageMimeType, VideoMimeType class TestChoice: @@ -293,7 +297,7 @@ def test_rejects_non_whole_float(self) -> None: constraint(42.5) def test_accepts_boolean_value(self) -> None: - """Test that bool is accepted (True=1, False=0) since bool is subclass of int.""" + """Test that bool is accepted (bool is subclass of int in Python).""" constraint = Int() assert constraint(True) == 1 @@ -588,3 +592,279 @@ def test_accepts_all_valid_images(self) -> None: result = constraint(artifacts) assert result == artifacts + + +class TestVideoConstraint: + """Test VideoConstraint validation for single video artifacts.""" + + def test_rejects_list_input(self) -> None: + """VideoConstraint requires single artifact, not a list.""" + constraint = VideoConstraint() + artifact = VideoArtifact(data=b"test") + + with pytest.raises( + ConstraintViolationError, + match=r"requires a single VideoArtifact, not a list", + ): + constraint([artifact]) # type: ignore[arg-type] + + def test_validates_video_artifact_type(self) -> None: + """VideoConstraint rejects non-VideoArtifact types.""" + constraint = VideoConstraint() + + with pytest.raises(ConstraintViolationError, match=r"Must be VideoArtifact"): + constraint("not an artifact") # type: ignore[arg-type] + + def test_accepts_valid_artifact(self) -> None: + """Valid VideoArtifact passes through unchanged.""" + constraint = VideoConstraint() + artifact = VideoArtifact(data=b"test video data") + + result = constraint(artifact) + + assert result is artifact + + def test_filters_mime_types_when_specified(self) -> None: + """VideoConstraint rejects unsupported MIME types.""" + constraint = VideoConstraint(supported_mime_types=[VideoMimeType.MP4]) + webm_artifact = VideoArtifact(data=b"test", mime_type=VideoMimeType.MOV) + + with pytest.raises(ConstraintViolationError, match=r"mime_type must be one of"): + constraint(webm_artifact) + + def test_accepts_supported_mime_type(self) -> None: + """VideoConstraint accepts artifact with supported MIME type.""" + constraint = VideoConstraint(supported_mime_types=[VideoMimeType.MP4]) + mp4_artifact = VideoArtifact(data=b"test", mime_type=VideoMimeType.MP4) + + result = constraint(mp4_artifact) + + assert result is mp4_artifact + + def test_accepts_any_mime_when_none_specified(self) -> None: + """No MIME filtering when supported_mime_types is None.""" + constraint = VideoConstraint(supported_mime_types=None) + artifact = VideoArtifact(data=b"test", mime_type=VideoMimeType.MOV) + + result = constraint(artifact) + + assert result is artifact + + +class TestVideosConstraint: + """Test VideosConstraint validation for video artifact lists.""" + + def test_normalizes_single_artifact_to_list(self) -> None: + """Single VideoArtifact is wrapped in a list.""" + constraint = VideosConstraint() + artifact = VideoArtifact(data=b"test") + + result = constraint(artifact) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0] is artifact + + def test_accepts_list_of_artifacts(self) -> None: + """List of VideoArtifacts passes through.""" + constraint = VideosConstraint() + artifacts = [VideoArtifact(data=b"1"), VideoArtifact(data=b"2")] + + result = constraint(artifacts) + + assert result == artifacts + + def test_enforces_max_count(self) -> None: + """VideosConstraint rejects when count exceeds max_count.""" + constraint = VideosConstraint(max_count=2) + artifacts = [VideoArtifact(data=b"x") for _ in range(3)] + + with pytest.raises(ConstraintViolationError, match=r"at most 2 video"): + constraint(artifacts) + + def test_validates_each_artifact_type(self) -> None: + """VideosConstraint reports index (1-indexed) of invalid artifact.""" + constraint = VideosConstraint(supported_mime_types=[VideoMimeType.MP4]) + artifacts = [ + VideoArtifact(data=b"1", mime_type=VideoMimeType.MP4), + "not an artifact", # Invalid at index 2 + VideoArtifact(data=b"3", mime_type=VideoMimeType.MP4), + ] + + with pytest.raises( + ConstraintViolationError, match=r"Video 2.*Must be VideoArtifact" + ): + constraint(artifacts) # type: ignore[arg-type] + + def test_filters_mime_types_per_video(self) -> None: + """Each video's MIME type is validated against supported types.""" + constraint = VideosConstraint(supported_mime_types=[VideoMimeType.MP4]) + artifacts = [ + VideoArtifact(data=b"1", mime_type=VideoMimeType.MP4), + VideoArtifact(data=b"2", mime_type=VideoMimeType.MOV), # Invalid + ] + + with pytest.raises( + ConstraintViolationError, match=r"Video 2.*mime_type must be one of" + ): + constraint(artifacts) + + def test_handles_empty_list(self) -> None: + """Empty list is valid (no videos to validate).""" + constraint = VideosConstraint() + + result = constraint([]) + + assert result == [] + + def test_accepts_all_valid_videos(self) -> None: + """All videos with valid MIME types pass validation.""" + constraint = VideosConstraint( + supported_mime_types=[VideoMimeType.MP4, VideoMimeType.MOV] + ) + artifacts = [ + VideoArtifact(data=b"1", mime_type=VideoMimeType.MP4), + VideoArtifact(data=b"2", mime_type=VideoMimeType.MOV), + ] + + result = constraint(artifacts) + + assert result == artifacts + + +class TestAudioConstraint: + """Test AudioConstraint validation for single audio artifacts.""" + + def test_rejects_list_input(self) -> None: + """AudioConstraint requires single artifact, not a list.""" + constraint = AudioConstraint() + artifact = AudioArtifact(data=b"test") + + with pytest.raises( + ConstraintViolationError, + match=r"requires a single AudioArtifact, not a list", + ): + constraint([artifact]) # type: ignore[arg-type] + + def test_validates_audio_artifact_type(self) -> None: + """AudioConstraint rejects non-AudioArtifact types.""" + constraint = AudioConstraint() + + with pytest.raises(ConstraintViolationError, match=r"Must be AudioArtifact"): + constraint("not an artifact") # type: ignore[arg-type] + + def test_accepts_valid_artifact(self) -> None: + """Valid AudioArtifact passes through unchanged.""" + constraint = AudioConstraint() + artifact = AudioArtifact(data=b"test audio data") + + result = constraint(artifact) + + assert result is artifact + + def test_filters_mime_types_when_specified(self) -> None: + """AudioConstraint rejects unsupported MIME types.""" + constraint = AudioConstraint(supported_mime_types=[AudioMimeType.MP3]) + wav_artifact = AudioArtifact(data=b"test", mime_type=AudioMimeType.WAV) + + with pytest.raises(ConstraintViolationError, match=r"mime_type must be one of"): + constraint(wav_artifact) + + def test_accepts_supported_mime_type(self) -> None: + """AudioConstraint accepts artifact with supported MIME type.""" + constraint = AudioConstraint(supported_mime_types=[AudioMimeType.MP3]) + mp3_artifact = AudioArtifact(data=b"test", mime_type=AudioMimeType.MP3) + + result = constraint(mp3_artifact) + + assert result is mp3_artifact + + def test_accepts_any_mime_when_none_specified(self) -> None: + """No MIME filtering when supported_mime_types is None.""" + constraint = AudioConstraint(supported_mime_types=None) + artifact = AudioArtifact(data=b"test", mime_type=AudioMimeType.WAV) + + result = constraint(artifact) + + assert result is artifact + + +class TestAudiosConstraint: + """Test AudiosConstraint validation for audio artifact lists.""" + + def test_normalizes_single_artifact_to_list(self) -> None: + """Single AudioArtifact is wrapped in a list.""" + constraint = AudiosConstraint() + artifact = AudioArtifact(data=b"test") + + result = constraint(artifact) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0] is artifact + + def test_accepts_list_of_artifacts(self) -> None: + """List of AudioArtifacts passes through.""" + constraint = AudiosConstraint() + artifacts = [AudioArtifact(data=b"1"), AudioArtifact(data=b"2")] + + result = constraint(artifacts) + + assert result == artifacts + + def test_enforces_max_count(self) -> None: + """AudiosConstraint rejects when count exceeds max_count.""" + constraint = AudiosConstraint(max_count=2) + artifacts = [AudioArtifact(data=b"x") for _ in range(3)] + + with pytest.raises(ConstraintViolationError, match=r"at most 2 audio"): + constraint(artifacts) + + def test_validates_each_artifact_type(self) -> None: + """AudiosConstraint reports index (1-indexed) of invalid artifact.""" + constraint = AudiosConstraint(supported_mime_types=[AudioMimeType.MP3]) + artifacts = [ + AudioArtifact(data=b"1", mime_type=AudioMimeType.MP3), + "not an artifact", # Invalid at index 2 + AudioArtifact(data=b"3", mime_type=AudioMimeType.MP3), + ] + + with pytest.raises( + ConstraintViolationError, match=r"Audio 2.*Must be AudioArtifact" + ): + constraint(artifacts) # type: ignore[arg-type] + + def test_filters_mime_types_per_audio(self) -> None: + """Each audio's MIME type is validated against supported types.""" + constraint = AudiosConstraint(supported_mime_types=[AudioMimeType.MP3]) + artifacts = [ + AudioArtifact(data=b"1", mime_type=AudioMimeType.MP3), + AudioArtifact(data=b"2", mime_type=AudioMimeType.WAV), # Invalid + ] + + with pytest.raises( + ConstraintViolationError, match=r"Audio 2.*mime_type must be one of" + ): + constraint(artifacts) + + def test_handles_empty_list(self) -> None: + """Empty list is valid (no audios to validate).""" + constraint = AudiosConstraint() + + result = constraint([]) + + assert result == [] + + def test_accepts_all_valid_audios(self) -> None: + """All audios with valid MIME types pass validation.""" + constraint = AudiosConstraint( + supported_mime_types=[AudioMimeType.MP3, AudioMimeType.WAV] + ) + artifacts = [ + AudioArtifact(data=b"1", mime_type=AudioMimeType.MP3), + AudioArtifact(data=b"2", mime_type=AudioMimeType.WAV), + ] + + result = constraint(artifacts) + + assert result == artifacts diff --git a/tests/unit_tests/test_credentials.py b/tests/unit_tests/test_credentials.py index 7c537f89..5a22df4c 100644 --- a/tests/unit_tests/test_credentials.py +++ b/tests/unit_tests/test_credentials.py @@ -8,24 +8,9 @@ from pydantic import SecretStr from celeste.core import Provider -from celeste.credentials import PROVIDER_CREDENTIAL_MAP, Credentials +from celeste.credentials import Credentials, get_auth_config, register_auth from celeste.exceptions import MissingCredentialsError -# Single source of truth for environment variable names -ENV_VAR_NAMES = [ - "OPENAI_API_KEY", - "ANTHROPIC_API_KEY", - "GOOGLE_API_KEY", - "MISTRAL_API_KEY", - "HUGGINGFACE_TOKEN", - "STABILITYAI_API_KEY", - "REPLICATE_API_TOKEN", - "COHERE_API_KEY", - "XAI_API_KEY", - "LUMA_API_KEY", - "TOPAZLABS_API_KEY", -] - @pytest.fixture(autouse=True) def clean_environment(monkeypatch: pytest.MonkeyPatch) -> Generator[None, None, None]: @@ -62,6 +47,52 @@ def clean_environment(monkeypatch: pytest.MonkeyPatch) -> Generator[None, None, yield +@pytest.fixture(autouse=True) +def clean_auth_registry( + clean_environment: Generator[None, None, None], +) -> Generator[None, None, None]: + """Keep auth registry deterministic across tests. + + Tests must not depend on provider package import side-effects. + """ + import importlib + + credentials_module = importlib.import_module("celeste.credentials") + + credentials_module._auth_registry.clear() + yield + credentials_module._auth_registry.clear() + + +@pytest.fixture(autouse=True) +def register_test_providers(clean_auth_registry: Generator[None, None, None]) -> None: + """Register a small set of providers for unit tests.""" + register_auth( # nosec B106 - env var name, not actual secret + provider=Provider.OPENAI, + secret_name="OPENAI_API_KEY", + header="Authorization", + prefix="Bearer ", + ) + register_auth( # nosec B106 - env var name, not actual secret + provider=Provider.ANTHROPIC, + secret_name="ANTHROPIC_API_KEY", + header="x-api-key", + prefix="", + ) + register_auth( # nosec B106 - env var name, not actual secret + provider=Provider.GOOGLE, + secret_name="GOOGLE_API_KEY", + header="x-goog-api-key", + prefix="", + ) + register_auth( # nosec B106 - env var name, not actual secret + provider=Provider.MISTRAL, + secret_name="MISTRAL_API_KEY", + header="Authorization", + prefix="Bearer ", + ) + + class TestCredentialsLoading: """Test loading credentials from environment.""" @@ -99,14 +130,19 @@ def test_load_multiple_providers(self, monkeypatch: pytest.MonkeyPatch) -> None: def test_empty_credentials(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test when no credentials are set.""" # Arrange - clear any existing env vars - for env_var in ENV_VAR_NAMES: + for env_var in ( + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GOOGLE_API_KEY", + "MISTRAL_API_KEY", + ): monkeypatch.delenv(env_var, raising=False) # Act creds = Credentials() # type: ignore[call-arg] - # Assert - verify ALL credential fields are None - for _provider, field_name in PROVIDER_CREDENTIAL_MAP.items(): + # Assert - verify all configured fields are None + for field_name in type(creds).model_fields: assert getattr(creds, field_name) is None, ( f"{field_name} should be None when no env vars set" ) @@ -229,8 +265,13 @@ def test_empty_list_when_no_credentials( self, monkeypatch: pytest.MonkeyPatch ) -> None: """Test empty list when no credentials configured.""" - # Arrange - clear all credential env vars - for env_var in ENV_VAR_NAMES: + # Arrange - clear any registered provider env vars (defensive) + for env_var in ( + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GOOGLE_API_KEY", + "MISTRAL_API_KEY", + ): monkeypatch.delenv(env_var, raising=False) creds = Credentials() # type: ignore[call-arg] @@ -307,30 +348,14 @@ def test_credentials_safe_for_logging( class TestProviderMapping: - """Test integrity of provider-credential mapping.""" - - def test_all_mapped_fields_exist(self) -> None: - """Test all fields in mapping exist on Credentials class.""" - # Arrange - creds = Credentials() # type: ignore[call-arg] - - # Act & Assert - verify each mapping points to a real field - for provider, field_name in PROVIDER_CREDENTIAL_MAP.items(): - assert hasattr(creds, field_name), ( - f"Missing field {field_name} for {provider}" - ) - - def test_all_providers_have_credential_mapping(self) -> None: - """Every Provider enum value has a corresponding entry in PROVIDER_CREDENTIAL_MAP.""" - # Get all providers that should have credentials - # Note: All providers in Provider enum require credentials - all_providers = list(Provider) - - # Verify each provider has a mapping - for provider in all_providers: - assert provider in PROVIDER_CREDENTIAL_MAP, ( - f"Provider {provider.value} missing from PROVIDER_CREDENTIAL_MAP" - ) + """Test integrity of registry-driven credential configuration.""" + + def test_get_auth_config_for_registered_providers(self) -> None: + """Registered providers should have an auth config.""" + secret_name, header, prefix = get_auth_config(Provider.OPENAI) # type: ignore[misc] + assert secret_name == "OPENAI_API_KEY" # nosec B105 - env var name + assert header == "Authorization" + assert prefix == "Bearer " class TestEdgeCases: diff --git a/tests/unit_tests/test_exceptions.py b/tests/unit_tests/test_exceptions.py index 663524a7..22b53071 100644 --- a/tests/unit_tests/test_exceptions.py +++ b/tests/unit_tests/test_exceptions.py @@ -8,12 +8,14 @@ ConstraintViolationError, Error, MissingCredentialsError, + ModalityNotFoundError, ModelNotFoundError, StreamEmptyError, StreamingNotSupportedError, StreamNotExhaustedError, UnsupportedCapabilityError, UnsupportedParameterError, + UnsupportedProviderError, ) from celeste.models import Model @@ -249,3 +251,120 @@ def test_can_access_exception_attributes(self) -> None: except UnsupportedParameterError as e: assert e.parameter == "seed" assert e.model_id == "gpt-4" + + +class TestModelNotFoundErrorMessagePaths: + """Test ModelNotFoundError message construction for all parameter combinations.""" + + def test_message_with_modality_and_provider(self) -> None: + """Test ModelNotFoundError message when modality and provider specified.""" + exc = ModelNotFoundError(modality="text", provider="openai") + assert exc.modality == "text" + assert exc.provider == "openai" + assert "No model found for modality 'text' with provider openai" in str(exc) + + def test_message_with_modality_only(self) -> None: + """Test ModelNotFoundError message when only modality specified.""" + exc = ModelNotFoundError(modality="images") + assert exc.modality == "images" + assert exc.provider is None + assert "No model found for modality 'images'" in str(exc) + + def test_message_with_model_id_and_provider(self) -> None: + """Test ModelNotFoundError message when model_id and provider specified.""" + exc = ModelNotFoundError(model_id="test-model", provider="anthropic") + assert exc.model_id == "test-model" + assert exc.provider == "anthropic" + assert "Model 'test-model' not found for provider anthropic" in str(exc) + + +class TestClientNotFoundErrorMessagePaths: + """Test ClientNotFoundError message construction for all parameter combinations.""" + + def test_message_with_modality_operation_and_provider(self) -> None: + """Test ClientNotFoundError message when modality, operation, and provider specified.""" + exc = ClientNotFoundError( + modality="text", operation="generate", provider="openai" + ) + assert exc.modality == "text" + assert exc.operation == "generate" + assert exc.provider == "openai" + assert ( + "No client registered for modality 'text', operation 'generate' with provider openai" + in str(exc) + ) + + def test_message_with_modality_and_provider(self) -> None: + """Test ClientNotFoundError message when modality and provider specified.""" + exc = ClientNotFoundError(modality="images", provider="openai") + assert exc.modality == "images" + assert exc.provider == "openai" + assert "No client registered for modality 'images' with provider openai" in str( + exc + ) + + def test_message_with_modality_only(self) -> None: + """Test ClientNotFoundError message when only modality specified.""" + exc = ClientNotFoundError(modality="videos") + assert exc.modality == "videos" + assert "No client registered for modality 'videos'" in str(exc) + + def test_message_with_capability_and_provider(self) -> None: + """Test ClientNotFoundError message when capability and provider specified.""" + exc = ClientNotFoundError(capability="text-generation", provider="openai") + assert exc.capability == "text-generation" + assert exc.provider == "openai" + assert "No client registered for text-generation with provider openai" in str( + exc + ) + + def test_message_with_no_parameters(self) -> None: + """Test ClientNotFoundError message when no parameters specified.""" + exc = ClientNotFoundError() + assert "No client registered" in str(exc) + + +class TestModalityNotFoundError: + """Test ModalityNotFoundError exception.""" + + def test_creates_with_modality_and_provider(self) -> None: + """Test exception stores modality and provider attributes.""" + exc = ModalityNotFoundError("text", "openai") + assert exc.modality == "text" + assert exc.provider == "openai" + assert "No client registered for modality 'text' with provider openai" in str( + exc + ) + + def test_creates_with_modality_only(self) -> None: + """Test exception stores modality attribute when provider is None.""" + exc = ModalityNotFoundError("images") + assert exc.modality == "images" + assert exc.provider is None + assert "No client registered for modality 'images'" in str(exc) + + +class TestUnsupportedProviderError: + """Test UnsupportedProviderError exception.""" + + def test_creates_with_provider(self) -> None: + """Test exception stores provider attribute.""" + exc = UnsupportedProviderError("unknown_provider") + assert exc.provider == "unknown_provider" + assert "unknown_provider" in str(exc) + + def test_message_is_descriptive(self) -> None: + """Test exception message clearly indicates the problem.""" + exc = UnsupportedProviderError("custom_provider") + message = str(exc) + assert "custom_provider" in message + assert "no credential mapping" in message.lower() + assert "not configured" in message.lower() + + def test_inherits_from_credentials_error(self) -> None: + """Test UnsupportedProviderError inherits from CredentialsError.""" + from celeste.exceptions import CredentialsError + + exc = UnsupportedProviderError("test_provider") + assert isinstance(exc, CredentialsError) + assert isinstance(exc, Error) diff --git a/tests/unit_tests/test_http.py b/tests/unit_tests/test_http.py index 415d0a4e..11f22f3d 100644 --- a/tests/unit_tests/test_http.py +++ b/tests/unit_tests/test_http.py @@ -6,7 +6,7 @@ import httpx import pytest -from celeste.core import Capability, Provider +from celeste.core import Modality, Provider from celeste.http import ( HTTPClient, clear_http_clients, @@ -361,7 +361,7 @@ def test_get_http_client_returns_same_instance_for_same_key(self) -> None: """get_http_client must return the same HTTPClient for identical provider/capability.""" # Arrange provider = Provider.OPENAI - capability = Capability.TEXT_GENERATION + capability = Modality.TEXT # Act client1 = get_http_client(provider, capability) @@ -375,7 +375,7 @@ def test_get_http_client_returns_different_instances_for_different_providers( ) -> None: """get_http_client must return different HTTPClients for different providers.""" # Arrange - capability = Capability.TEXT_GENERATION + capability = Modality.TEXT # Act openai_client = get_http_client(Provider.OPENAI, capability) @@ -392,8 +392,8 @@ def test_get_http_client_returns_different_instances_for_different_capabilities( provider = Provider.OPENAI # Act - text_client = get_http_client(provider, Capability.TEXT_GENERATION) - image_client = get_http_client(provider, Capability.IMAGE_GENERATION) + text_client = get_http_client(provider, Modality.TEXT) + image_client = get_http_client(provider, Modality.IMAGES) # Assert - Must be different instances assert text_client is not image_client @@ -401,18 +401,14 @@ def test_get_http_client_returns_different_instances_for_different_capabilities( def test_registry_isolation_prevents_cross_contamination(self) -> None: """Registry must maintain complete isolation between different provider/capability pairs.""" # Arrange - Create clients for different combinations - openai_text = get_http_client(Provider.OPENAI, Capability.TEXT_GENERATION) - openai_image = get_http_client(Provider.OPENAI, Capability.IMAGE_GENERATION) - anthropic_text = get_http_client(Provider.ANTHROPIC, Capability.TEXT_GENERATION) + openai_text = get_http_client(Provider.OPENAI, Modality.TEXT) + openai_image = get_http_client(Provider.OPENAI, Modality.IMAGES) + anthropic_text = get_http_client(Provider.ANTHROPIC, Modality.TEXT) # Act - Retrieve them again - openai_text_again = get_http_client(Provider.OPENAI, Capability.TEXT_GENERATION) - openai_image_again = get_http_client( - Provider.OPENAI, Capability.IMAGE_GENERATION - ) - anthropic_text_again = get_http_client( - Provider.ANTHROPIC, Capability.TEXT_GENERATION - ) + openai_text_again = get_http_client(Provider.OPENAI, Modality.TEXT) + openai_image_again = get_http_client(Provider.OPENAI, Modality.IMAGES) + anthropic_text_again = get_http_client(Provider.ANTHROPIC, Modality.TEXT) # Assert - Same pairs return same instances, different pairs return different instances assert openai_text is openai_text_again @@ -450,8 +446,8 @@ async def test_close_all_http_clients_closes_all_and_clears_registry( from celeste.http import _http_clients, get_http_client # Arrange - Create multiple clients - client1 = get_http_client(Provider.OPENAI, Capability.TEXT_GENERATION) - client2 = get_http_client(Provider.ANTHROPIC, Capability.TEXT_GENERATION) + client1 = get_http_client(Provider.OPENAI, Modality.TEXT) + client2 = get_http_client(Provider.ANTHROPIC, Modality.TEXT) # Initialize both clients to create httpx.AsyncClient instances with patch("celeste.http.httpx.AsyncClient", return_value=mock_httpx_client): @@ -494,8 +490,8 @@ async def test_close_all_http_clients_calls_aclose_on_each_client( mock_client2.post = AsyncMock(return_value=httpx.Response(200)) mock_client2.aclose = AsyncMock() - client1 = get_http_client(Provider.OPENAI, Capability.TEXT_GENERATION) - client2 = get_http_client(Provider.ANTHROPIC, Capability.TEXT_GENERATION) + client1 = get_http_client(Provider.OPENAI, Modality.TEXT) + client2 = get_http_client(Provider.ANTHROPIC, Modality.TEXT) # Initialize clients with different mock instances with patch("celeste.http.httpx.AsyncClient") as mock_constructor: @@ -528,7 +524,7 @@ async def test_clear_http_clients_clears_without_closing(self) -> None: mock_client1.post = AsyncMock(return_value=httpx.Response(200)) mock_client1.aclose = AsyncMock() - client1 = get_http_client(Provider.OPENAI, Capability.TEXT_GENERATION) + client1 = get_http_client(Provider.OPENAI, Modality.TEXT) # Initialize client with patch("celeste.http.httpx.AsyncClient", return_value=mock_client1): @@ -567,9 +563,9 @@ async def test_close_all_handles_multiple_providers_and_capabilities( mock_client3.post = AsyncMock(return_value=httpx.Response(200)) mock_client3.aclose = AsyncMock() - client1 = get_http_client(Provider.OPENAI, Capability.TEXT_GENERATION) - client2 = get_http_client(Provider.OPENAI, Capability.IMAGE_GENERATION) - client3 = get_http_client(Provider.ANTHROPIC, Capability.TEXT_GENERATION) + client1 = get_http_client(Provider.OPENAI, Modality.TEXT) + client2 = get_http_client(Provider.OPENAI, Modality.IMAGES) + client3 = get_http_client(Provider.ANTHROPIC, Modality.TEXT) # Initialize all clients with patch("celeste.http.httpx.AsyncClient") as mock_constructor: @@ -616,8 +612,8 @@ async def test_close_all_continues_despite_individual_failures(self) -> None: mock_client2.post = AsyncMock(return_value=httpx.Response(200)) mock_client2.aclose = AsyncMock() - client1 = get_http_client(Provider.OPENAI, Capability.TEXT_GENERATION) - client2 = get_http_client(Provider.ANTHROPIC, Capability.TEXT_GENERATION) + client1 = get_http_client(Provider.OPENAI, Modality.TEXT) + client2 = get_http_client(Provider.ANTHROPIC, Modality.TEXT) # Initialize both clients with patch("celeste.http.httpx.AsyncClient") as mock_constructor: diff --git a/tests/unit_tests/test_init.py b/tests/unit_tests/test_init.py index e0375998..10a83f47 100644 --- a/tests/unit_tests/test_init.py +++ b/tests/unit_tests/test_init.py @@ -5,8 +5,18 @@ import pytest from pydantic import SecretStr -from celeste import Capability, Model, Provider, create_client -from celeste.exceptions import ModelNotFoundError +import celeste +from celeste import ( + Capability, + Modality, + Model, + Operation, + Provider, + _infer_operation, + _resolve_model, + create_client, +) +from celeste.exceptions import ClientNotFoundError, ModelNotFoundError @pytest.fixture @@ -43,37 +53,34 @@ def test_create_client_no_models_available_raises_error(self) -> None: # Arrange mock_list_models.return_value = [] - # Act & Assert + # Act & Assert - capability is translated to modality, so error mentions modality with pytest.raises( ModelNotFoundError, - match=rf"No model found for capability.*{Capability.TEXT_GENERATION}", + match=r"No model found for modality 'text'", ): create_client(capability=Capability.TEXT_GENERATION) - def test_create_client_unregistered_model_with_provider_emits_warning(self) -> None: - """Test that unregistered model with explicit provider emits warning and proceeds.""" + def test_create_client_unregistered_model_creates_fallback_with_warning( + self, + ) -> None: + """Test that unregistered model with provider creates fallback with warning.""" with ( patch("celeste.get_model", autospec=True) as mock_get_model, - patch("celeste.get_client_class", autospec=True) as mock_get_client_class, + patch.dict(celeste._CLIENT_MAP, {}, clear=True), ): # Arrange mock_get_model.return_value = None - mock_get_client_class.side_effect = NotImplementedError( - "Client not registered" - ) - # Act & Assert - should warn but not raise ModelNotFoundError + # Act & Assert - should warn and create fallback model with ( - pytest.warns( - UserWarning, - match=r"Model.*not registered.*Parameter validation disabled", - ), - pytest.raises(NotImplementedError), + pytest.warns(UserWarning, match=r"not registered in Celeste"), + pytest.raises(ClientNotFoundError), # No client in _CLIENT_MAP ): create_client( capability=Capability.TEXT_GENERATION, provider=Provider.OPENAI, - model="nonexistent-model", + model="unregistered-model", + api_key=SecretStr("dummy"), ) def test_create_client_uses_explicit_model_when_both_provided( @@ -82,18 +89,13 @@ def test_create_client_uses_explicit_model_when_both_provided( """Test that create_client uses get_model for explicit selection.""" with ( patch("celeste.get_model", autospec=True) as mock_get_model, - patch("celeste.get_client_class", autospec=True) as mock_get_client_class, + patch.dict(celeste._CLIENT_MAP, {}, clear=True), ): # Arrange mock_get_model.return_value = sample_models[1] # claude-3 - mock_get_client_class.side_effect = NotImplementedError( - "Client not registered" - ) # Act & Assert - with pytest.raises( - NotImplementedError - ): # _get_client_class not implemented + with pytest.raises(ClientNotFoundError): # No client in _CLIENT_MAP create_client( capability=Capability.TEXT_GENERATION, provider=Provider.ANTHROPIC, @@ -118,27 +120,24 @@ def test_create_client_filters_by_provider_when_specified( """Test that provider filtering is applied when provider is specified.""" with ( patch("celeste.list_models", autospec=True) as mock_list_models, - patch("celeste.get_client_class", autospec=True) as mock_get_client_class, + patch.dict(celeste._CLIENT_MAP, {}, clear=True), ): # Arrange mock_list_models.return_value = [sample_models[1]] # claude-3 - mock_get_client_class.side_effect = NotImplementedError( - "Client not registered" - ) - # Act - Should fail at _get_client_class but provider filtering should work - with pytest.raises( - NotImplementedError - ): # _get_client_class not implemented + # Act - Should fail at _CLIENT_MAP lookup but provider filtering should work + with pytest.raises(ClientNotFoundError): # No client in _CLIENT_MAP create_client( capability=Capability.TEXT_GENERATION, provider=Provider.ANTHROPIC, api_key=SecretStr("dummy"), ) - # Assert - verify provider was passed to list_models + # Assert - capability is translated to modality/operation before calling list_models mock_list_models.assert_called_once_with( - provider=Provider.ANTHROPIC, capability=Capability.TEXT_GENERATION + provider=Provider.ANTHROPIC, + modality=Modality.TEXT, + operation=Operation.GENERATE, ) @@ -150,7 +149,7 @@ def test_model_selection_precedence(self, sample_models: list[Model]) -> None: with ( patch("celeste.list_models", autospec=True) as mock_list_models, patch("celeste.get_model", autospec=True) as mock_get_model, - patch("celeste.get_client_class", autospec=True) as mock_get_client_class, + patch.dict(celeste._CLIENT_MAP, {}, clear=True), ): # Arrange explicit_model = sample_models[1] # claude-3 @@ -158,12 +157,9 @@ def test_model_selection_precedence(self, sample_models: list[Model]) -> None: mock_get_model.return_value = explicit_model mock_list_models.return_value = [auto_model] - mock_get_client_class.side_effect = NotImplementedError( - "Client not registered" - ) - # Act - Should fail at _get_client_class but precedence should work - with pytest.raises(NotImplementedError): + # Act - Should fail at _CLIENT_MAP lookup but precedence should work + with pytest.raises(ClientNotFoundError): create_client( capability=Capability.TEXT_GENERATION, provider=Provider.ANTHROPIC, @@ -183,3 +179,249 @@ def test_error_propagation_from_registry(self) -> None: with pytest.raises(ValueError, match="Registry error"): create_client(capability=Capability.TEXT_GENERATION) + + +class TestInferOperation: + """Test _infer_operation helper function.""" + + def test_infer_operation_modality_not_supported(self) -> None: + """Test that _infer_operation raises when modality is not supported.""" + model = Model( + id="test-model", + provider=Provider.OPENAI, + display_name="Test Model", + operations={Modality.TEXT: {Operation.GENERATE}}, + ) + + with pytest.raises( + ValueError, match="Model 'test-model' does not support modality 'images'" + ): + _infer_operation(model, Modality.IMAGES) + + def test_infer_operation_single_operation_auto_infer(self) -> None: + """Test that _infer_operation returns the operation when only one is available.""" + model = Model( + id="test-model", + provider=Provider.OPENAI, + display_name="Test Model", + operations={Modality.TEXT: {Operation.GENERATE}}, + ) + + result = _infer_operation(model, Modality.TEXT) + assert result == Operation.GENERATE + + def test_infer_operation_multiple_operations_requires_explicit(self) -> None: + """Test that _infer_operation raises when multiple operations are available.""" + model = Model( + id="test-model", + provider=Provider.OPENAI, + display_name="Test Model", + operations={ + Modality.IMAGES: {Operation.GENERATE, Operation.EDIT}, + }, + ) + + with pytest.raises( + ValueError, + match=r"Model 'test-model' supports multiple operations for images: .*\. Specify 'operation' explicitly\.", + ): + _infer_operation(model, Modality.IMAGES) + + def test_infer_operation_no_operations_error(self) -> None: + """Test that _infer_operation raises when no operations are registered.""" + model = Model( + id="test-model", + provider=Provider.OPENAI, + display_name="Test Model", + operations={Modality.TEXT: set()}, + ) + + with pytest.raises( + ValueError, + match="Model 'test-model' has no registered operations for modality 'text'", + ): + _infer_operation(model, Modality.TEXT) + + +class TestCreateClientModality: + """Test create_client with modality parameter (new v1 API).""" + + def test_create_client_with_modality_basic(self) -> None: + """Test basic modality client creation.""" + model = Model( + id="gpt-4o", + provider=Provider.OPENAI, + display_name="GPT-4o", + operations={Modality.TEXT: {Operation.GENERATE}}, + ) + + with ( + patch("celeste.list_models", autospec=True) as mock_list_models, + patch.dict(celeste._CLIENT_MAP, {}, clear=True), + ): + mock_list_models.return_value = [model] + + with pytest.raises(ClientNotFoundError): + create_client(modality=Modality.TEXT) + + mock_list_models.assert_called_once_with( + provider=None, + modality=Modality.TEXT, + operation=None, + ) + + def test_create_client_with_modality_string_conversion(self) -> None: + """Test that string modality is converted to Modality enum.""" + model = Model( + id="gpt-4o", + provider=Provider.OPENAI, + display_name="GPT-4o", + operations={Modality.TEXT: {Operation.GENERATE}}, + ) + + with ( + patch("celeste.list_models", autospec=True) as mock_list_models, + patch.dict(celeste._CLIENT_MAP, {}, clear=True), + ): + mock_list_models.return_value = [model] + + with pytest.raises(ClientNotFoundError): + create_client(modality="text") + + mock_list_models.assert_called_once_with( + provider=None, + modality=Modality.TEXT, + operation=None, + ) + + def test_create_client_with_modality_and_operation(self) -> None: + """Test create_client with both modality and operation specified.""" + model = Model( + id="gpt-4o", + provider=Provider.OPENAI, + display_name="GPT-4o", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, + ) + + with ( + patch("celeste.list_models", autospec=True) as mock_list_models, + patch.dict(celeste._CLIENT_MAP, {}, clear=True), + ): + mock_list_models.return_value = [model] + + with pytest.raises(ClientNotFoundError): + create_client(modality=Modality.TEXT, operation=Operation.ANALYZE) + + mock_list_models.assert_called_once_with( + provider=None, + modality=Modality.TEXT, + operation=Operation.ANALYZE, + ) + + def test_create_client_with_modality_model_resolution(self) -> None: + """Test that model is resolved correctly with modality.""" + model = Model( + id="gpt-4o", + provider=Provider.OPENAI, + display_name="GPT-4o", + operations={Modality.TEXT: {Operation.GENERATE}}, + ) + + with ( + patch("celeste.get_model", autospec=True) as mock_get_model, + patch.dict(celeste._CLIENT_MAP, {}, clear=True), + ): + mock_get_model.return_value = model + + with pytest.raises(ClientNotFoundError): + create_client(modality=Modality.TEXT, model="gpt-4o") + + mock_get_model.assert_called_once_with("gpt-4o", None) + + +class TestResolveModel: + """Test _resolve_model helper function.""" + + def test_resolve_model_no_modality_raises(self) -> None: + """Test that _resolve_model raises when modality not provided.""" + with pytest.raises( + ValueError, + match="Either 'modality' or 'model' must be provided", + ): + _resolve_model() + + def test_resolve_model_with_modality_no_models_raises(self) -> None: + """Test that _resolve_model raises ModelNotFoundError when no models found.""" + with patch("celeste.list_models", autospec=True) as mock_list_models: + mock_list_models.return_value = [] + with pytest.raises(ModelNotFoundError): + _resolve_model(modality=Modality.TEXT) + + def test_resolve_model_with_modality_returns_first_model(self) -> None: + """Test that _resolve_model returns first model when multiple available.""" + model1 = Model( + id="model-1", + provider=Provider.OPENAI, + display_name="Model 1", + operations={Modality.TEXT: {Operation.GENERATE}}, + ) + model2 = Model( + id="model-2", + provider=Provider.OPENAI, + display_name="Model 2", + operations={Modality.TEXT: {Operation.GENERATE}}, + ) + with patch("celeste.list_models", autospec=True) as mock_list_models: + mock_list_models.return_value = [model1, model2] + result = _resolve_model(modality=Modality.TEXT) + assert result == model1 + + def test_resolve_model_string_model_not_found_no_provider_raises(self) -> None: + """Test that _resolve_model raises ModelNotFoundError for string model without provider.""" + with patch("celeste.get_model", autospec=True) as mock_get_model: + mock_get_model.return_value = None + with pytest.raises(ModelNotFoundError): + _resolve_model(model="nonexistent-model") + + def test_resolve_model_string_model_not_found_with_provider_creates_fallback( + self, + ) -> None: + """Test that _resolve_model creates fallback model when string model not found with provider.""" + with ( + patch("celeste.get_model", autospec=True) as mock_get_model, + pytest.warns(UserWarning, match=r"not registered in Celeste"), + ): + mock_get_model.return_value = None + result = _resolve_model( + model="unregistered-model", + provider=Provider.OPENAI, + modality=Modality.TEXT, + ) + assert result.id == "unregistered-model" + assert result.provider == Provider.OPENAI + assert Modality.TEXT in result.operations + + def test_resolve_model_string_model_not_found_no_modality_raises( + self, + ) -> None: + """Test that _resolve_model raises ValueError when model not found and no modality.""" + with ( + patch("celeste.get_model", autospec=True) as mock_get_model, + ): + mock_get_model.return_value = None + with pytest.raises( + ValueError, + match=r"Model 'nonexistent' not registered. Specify 'modality' explicitly\.", + ): + _resolve_model(model="nonexistent", provider=Provider.OPENAI) + + def test_resolve_model_returns_model_object_directly(self) -> None: + """Test that _resolve_model returns Model object when passed directly.""" + model = Model( + id="test-model", + provider=Provider.OPENAI, + display_name="Test Model", + operations={Modality.TEXT: {Operation.GENERATE}}, + ) + result = _resolve_model(model=model) + assert result == model diff --git a/tests/unit_tests/test_io.py b/tests/unit_tests/test_io.py new file mode 100644 index 00000000..ad16850f --- /dev/null +++ b/tests/unit_tests/test_io.py @@ -0,0 +1,93 @@ +"""Tests for IO utilities - input type introspection functions.""" + +from typing import cast, get_args, get_origin + +from celeste.artifacts import AudioArtifact, ImageArtifact, VideoArtifact +from celeste.constraints import ( + Bool, + ImageConstraint, + Str, + VideoConstraint, + VideosConstraint, +) +from celeste.core import InputType +from celeste.io import ( + _extract_input_type, + get_constraint_input_type, +) + + +class TestExtractInputType: + """Test _extract_input_type function.""" + + def test_extract_input_type_direct_match(self) -> None: + """Test that _extract_input_type returns InputType for direct matches.""" + assert _extract_input_type(str) == InputType.TEXT + assert _extract_input_type(ImageArtifact) == InputType.IMAGE + assert _extract_input_type(VideoArtifact) == InputType.VIDEO + assert _extract_input_type(AudioArtifact) == InputType.AUDIO + + def test_extract_input_type_union_type(self) -> None: + """Test that _extract_input_type extracts from union types.""" + union_type = str | ImageArtifact + result = _extract_input_type(cast(type, union_type)) + # Should return the first match found (order may vary, so check it's one of them) + assert result in {InputType.TEXT, InputType.IMAGE} + + def test_extract_input_type_list_generic(self) -> None: + """Test that _extract_input_type extracts from list generics.""" + + list_type = list[ImageArtifact] + origin = get_origin(list_type) + assert origin is list + args = get_args(list_type) + assert len(args) == 1 + result = _extract_input_type(args[0]) # Extract from the inner type + assert result == InputType.IMAGE + + def test_extract_input_type_nested_union(self) -> None: + """Test that _extract_input_type handles nested unions.""" + nested_union = str | ImageArtifact | VideoArtifact + result = _extract_input_type(cast(type, nested_union)) + # Should find one of the types + assert result in {InputType.TEXT, InputType.IMAGE, InputType.VIDEO} + + def test_extract_input_type_unmapped_type_returns_none(self) -> None: + """Test that _extract_input_type returns None for unmapped types.""" + assert _extract_input_type(int) is None + assert _extract_input_type(float) is None + assert _extract_input_type(dict) is None + + +class TestGetConstraintInputType: + """Test get_constraint_input_type function.""" + + def test_get_constraint_input_type_with_image_constraint(self) -> None: + """Test that get_constraint_input_type extracts InputType from ImageConstraint.""" + constraint = ImageConstraint() + result = get_constraint_input_type(constraint) + assert result == InputType.IMAGE + + def test_get_constraint_input_type_with_video_constraint(self) -> None: + """Test that get_constraint_input_type extracts InputType from VideoConstraint.""" + constraint = VideoConstraint() + result = get_constraint_input_type(constraint) + assert result == InputType.VIDEO + + def test_get_constraint_input_type_with_videos_constraint(self) -> None: + """Test that get_constraint_input_type extracts InputType from VideosConstraint.""" + constraint = VideosConstraint() + result = get_constraint_input_type(constraint) + assert result == InputType.VIDEO + + def test_get_constraint_input_type_with_str_constraint_returns_text(self) -> None: + """Test that get_constraint_input_type returns TEXT for Str constraint.""" + constraint = Str(min_length=1) + result = get_constraint_input_type(constraint) + assert result == InputType.TEXT + + def test_get_constraint_input_type_with_no_artifact_type_returns_none(self) -> None: + """Test that get_constraint_input_type returns None for constraints without mapped types.""" + constraint = Bool() + result = get_constraint_input_type(constraint) + assert result is None diff --git a/tests/unit_tests/test_models.py b/tests/unit_tests/test_models.py index 0baac727..f8bc6e30 100644 --- a/tests/unit_tests/test_models.py +++ b/tests/unit_tests/test_models.py @@ -1,7 +1,6 @@ """Tests for models and model registry.""" from collections.abc import Generator -from unittest.mock import MagicMock, Mock, patch import pytest @@ -41,11 +40,8 @@ class TestRegisterModels: """Test model registration functionality.""" @pytest.mark.smoke - @patch("celeste.registry._load_from_entry_points") - def test_register_models_accepts_single_or_list(self, mock_load: Mock) -> None: + def test_register_models_accepts_single_or_list(self) -> None: """Registering models works with both single model and list.""" - # Prevent entry point loading from interfering with test isolation - mock_load.return_value = None single_model = SAMPLE_MODELS[0] register_models(single_model, Capability.TEXT_GENERATION) retrieved = get_model(single_model.id, single_model.provider) @@ -66,11 +62,8 @@ def test_register_models_accepts_single_or_list(self, mock_load: Mock) -> None: assert model.provider == retrieved.provider assert Capability.TEXT_GENERATION in retrieved.capabilities - @patch("celeste.registry._load_from_entry_points") - def test_reregistering_same_key_raises_error(self, mock_load: Mock) -> None: + def test_reregistering_same_key_raises_error(self) -> None: """Re-registering with same (id, provider) but different display_name raises ValueError.""" - # Prevent entry point loading from interfering with test isolation - mock_load.return_value = None original = SAMPLE_MODELS[0] register_models(original, Capability.TEXT_GENERATION) @@ -88,13 +81,8 @@ def test_reregistering_same_key_raises_error(self, mock_load: Mock) -> None: assert result.display_name == original.display_name assert len(list_models()) == 1 - @patch("celeste.registry._load_from_entry_points") - def test_registering_same_model_for_multiple_capabilities_merges( - self, mock_load: Mock - ) -> None: + def test_registering_same_model_for_multiple_capabilities_merges(self) -> None: """Registering the same model for multiple capabilities merges capabilities.""" - # Prevent entry point loading from interfering with test isolation - mock_load.return_value = None model = Model( id="multi-cap-model", provider=Provider.OPENAI, @@ -105,7 +93,7 @@ def test_registering_same_model_for_multiple_capabilities_merges( retrieved = get_model("multi-cap-model", Provider.OPENAI) assert retrieved is not None assert Capability.TEXT_GENERATION in retrieved.capabilities - assert Capability.EMBEDDINGS not in retrieved.capabilities + assert Capability.TEXT_EMBEDDINGS not in retrieved.capabilities # Register same model for different capability embeddings_model = Model( @@ -113,12 +101,12 @@ def test_registering_same_model_for_multiple_capabilities_merges( provider=Provider.OPENAI, display_name="Multi-Cap Model", ) - register_models(embeddings_model, Capability.EMBEDDINGS) + register_models(embeddings_model, Capability.TEXT_EMBEDDINGS) retrieved = get_model("multi-cap-model", Provider.OPENAI) assert retrieved is not None assert Capability.TEXT_GENERATION in retrieved.capabilities - assert Capability.EMBEDDINGS in retrieved.capabilities + assert Capability.TEXT_EMBEDDINGS in retrieved.capabilities assert len(list_models()) == 1 @@ -126,10 +114,8 @@ class TestListModels: """Test model listing and filtering functionality.""" @pytest.fixture(autouse=True) - def setup_models(self, monkeypatch: pytest.MonkeyPatch) -> None: + def setup_models(self) -> None: """Set up test models for filtering tests.""" - # Prevent entry point loading from interfering with test isolation - monkeypatch.setattr("celeste.registry._load_from_entry_points", lambda: None) register_models(SAMPLE_MODELS[0], Capability.TEXT_GENERATION) register_models(SAMPLE_MODELS[1], Capability.IMAGE_GENERATION) register_models(SAMPLE_MODELS[2], Capability.TEXT_GENERATION) @@ -242,58 +228,6 @@ def test_same_id_different_providers_are_distinct(self) -> None: assert retrieved2.display_name == model2.display_name -class TestEntryPoints: - """Test entry point loading functionality.""" - - @patch("celeste.registry.importlib.metadata.entry_points") - def test_entry_point_loading_success( - self, mock_entry_points: Mock, capsys: pytest.CaptureFixture[str] - ) -> None: - """Successful loading of models from entry points.""" - mock_ep = MagicMock() - mock_ep.name = "test_models" - test_model = Model( - id="ep-test-model", - provider=Provider.OPENAI, - display_name="Entry Point Test Model", - ) - mock_ep.load.return_value = lambda: register_models( - test_model, Capability.TEXT_GENERATION - ) - - mock_entry_points.return_value = [mock_ep] - - clear() - from celeste.registry import _load_from_entry_points - - _load_from_entry_points() - - models = list_models() - assert any(m.id == "ep-test-model" for m in models) - - captured = capsys.readouterr() - assert captured.err == "" - - @patch("celeste.registry.importlib.metadata.entry_points") - def test_entry_point_returns_none_handled( - self, mock_entry_points: Mock, capsys: pytest.CaptureFixture[str] - ) -> None: - """Entry points returning None are handled gracefully.""" - mock_ep = MagicMock() - mock_ep.name = "empty_models" - mock_ep.load.return_value = lambda: None - - mock_entry_points.return_value = [mock_ep] - - clear() - from celeste.registry import _load_from_entry_points - - _load_from_entry_points() - - captured = capsys.readouterr() - assert captured.err == "" - - class TestParameterSupport: """Test registry with models that have supported parameters.""" @@ -348,11 +282,8 @@ def test_list_models_includes_parameters(self) -> None: class TestClear: """Test registry clearing functionality.""" - @patch("celeste.registry._load_from_entry_points") - def test_clear_removes_all_models(self, mock_load: Mock) -> None: + def test_clear_removes_all_models(self) -> None: """clear removes all registered models.""" - # Prevent entry point loading from interfering with test isolation - mock_load.return_value = None register_models(SAMPLE_MODELS[0], Capability.TEXT_GENERATION) register_models(SAMPLE_MODELS[1], Capability.IMAGE_GENERATION) register_models(SAMPLE_MODELS[2], Capability.TEXT_GENERATION) diff --git a/tests/unit_tests/test_parameters.py b/tests/unit_tests/test_parameters.py index 13c7cd03..2de6cc5f 100644 --- a/tests/unit_tests/test_parameters.py +++ b/tests/unit_tests/test_parameters.py @@ -5,9 +5,11 @@ import pytest -from celeste.core import Parameter +from celeste.constraints import Range, Str +from celeste.core import Parameter, Provider +from celeste.exceptions import ConstraintViolationError, UnsupportedParameterError from celeste.models import Model -from celeste.types import StructuredOutput +from celeste.types import TextContent class DefaultParseOutputMapper: @@ -21,9 +23,7 @@ def map(self, request: dict[str, Any], value: Any, model: Model) -> dict[str, An request["temperature"] = value return request - def parse_output( - self, content: StructuredOutput, value: object | None - ) -> StructuredOutput: + def parse_output(self, content: TextContent, value: object | None) -> TextContent: """Default implementation: return content unchanged.""" return content @@ -44,7 +44,7 @@ class TestParameterMapperProtocol: ], ) def test_parse_output_returns_content_unchanged( - self, content: StructuredOutput, value: object | None + self, content: TextContent, value: object | None ) -> None: """Default parse_output implementation returns content unchanged. @@ -59,3 +59,133 @@ def test_parse_output_returns_content_unchanged( # Assert assert result is content + + +class TestParameterMapperBaseClass: + """Test ParameterMapper base class methods.""" + + def test_parse_output_default_returns_content_unchanged(self) -> None: + """Test that ParameterMapper.parse_output default returns content unchanged.""" + from celeste.parameters import ParameterMapper + + class TestMapper(ParameterMapper): + name = Parameter.TEMPERATURE + + def map( + self, + request: dict[str, Any], + value: Any, # noqa: ANN401 + model: Model, + ) -> dict[str, Any]: + return request + + mapper = TestMapper() + content: TextContent = "test content" + result = mapper.parse_output(content, value=None) + assert result is content + + def test_validate_value_with_none_returns_none(self) -> None: + """Test that _validate_value returns None when value is None.""" + from celeste.parameters import ParameterMapper + + class TestMapper(ParameterMapper): + name = Parameter.TEMPERATURE + + def map( + self, + request: dict[str, Any], + value: Any, # noqa: ANN401 + model: Model, + ) -> dict[str, Any]: + return request + + mapper = TestMapper() + model = Model( + id="test-model", + provider=Provider.OPENAI, + display_name="Test Model", + ) + result = mapper._validate_value(None, model) + assert result is None + + def test_validate_value_with_unsupported_parameter_raises(self) -> None: + """Test that _validate_value raises UnsupportedParameterError when parameter not in model.""" + from celeste.parameters import ParameterMapper + + class TestMapper(ParameterMapper): + name = Parameter.TEMPERATURE + + def map( + self, + request: dict[str, Any], + value: Any, # noqa: ANN401 + model: Model, + ) -> dict[str, Any]: + return request + + mapper = TestMapper() + model = Model( + id="test-model", + provider=Provider.OPENAI, + display_name="Test Model", + parameter_constraints={}, # No constraints = parameter not supported + ) + with pytest.raises( + UnsupportedParameterError, + match="Parameter 'temperature' is not supported by model 'test-model'", + ): + mapper._validate_value(0.7, model) + + def test_validate_value_with_valid_constraint_calls_constraint(self) -> None: + """Test that _validate_value calls constraint when parameter is supported.""" + from celeste.parameters import ParameterMapper + + class TestMapper(ParameterMapper): + name = Parameter.TEMPERATURE + + def map( + self, + request: dict[str, Any], + value: Any, # noqa: ANN401 + model: Model, + ) -> dict[str, Any]: + return request + + mapper = TestMapper() + constraint = Str(min_length=1) + model = Model( + id="test-model", + provider=Provider.OPENAI, + display_name="Test Model", + parameter_constraints={Parameter.TEMPERATURE: constraint}, + ) + # Note: This will fail validation because Str constraint expects str, not float + # But it tests that the constraint is called + with pytest.raises(ConstraintViolationError): + mapper._validate_value(0.7, model) + + def test_validate_value_with_valid_constraint_returns_validated_value(self) -> None: + """Test that _validate_value returns constraint-validated value.""" + from celeste.parameters import ParameterMapper + + class TestMapper(ParameterMapper): + name = Parameter.TEMPERATURE + + def map( + self, + request: dict[str, Any], + value: Any, # noqa: ANN401 + model: Model, + ) -> dict[str, Any]: + return request + + mapper = TestMapper() + constraint = Range(min=0.0, max=1.0) + model = Model( + id="test-model", + provider=Provider.OPENAI, + display_name="Test Model", + parameter_constraints={Parameter.TEMPERATURE: constraint}, + ) + result = mapper._validate_value(0.7, model) + assert result == 0.7 diff --git a/tests/unit_tests/test_provider_api_templates.py b/tests/unit_tests/test_provider_api_templates.py new file mode 100644 index 00000000..be29b5ae --- /dev/null +++ b/tests/unit_tests/test_provider_api_templates.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +import ast +import re +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class TemplateExpectations: + usage_map_return: str + parse_usage_return: str + + +TEMPLATE_REQUIRED_METHODS: set[str] = { + "_make_request", + "_make_stream_request", + "map_usage_fields", + "_parse_usage", + "_parse_content", + "_parse_finish_reason", + "_build_metadata", +} + + +def _repo_root() -> Path: + # This file lives at: celeste-python-private/tests/unit_tests/test_provider_api_templates.py + return Path(__file__).resolve().parents[2] + + +def _template_client_path() -> Path: + root = _repo_root() + return ( + root + / "templates" + / "providers" + / "{provider_slug}" + / "src" + / "celeste_{provider_slug}" + / "{api_slug}" + / "client.py.template" + ) + + +def _extract_template_expectations(template_text: str) -> TemplateExpectations: + # Return annotations are stable and placeholder-free; we can regex them out of the template. + def_ret_re = re.compile( + r"^\s*(?:async\s+def|def)\s+(?P[a-zA-Z0-9_]+)\s*\([^\)]*\)\s*(?:->\s*(?P[^:]+))?:", + re.M, + ) + + returns: dict[str, str | None] = {} + for m in def_ret_re.finditer(template_text): + name = m.group("name") + returns[name] = (m.group("ret") or "").strip() or None + + usage_map_return = returns.get("map_usage_fields") + parse_usage_return = returns.get("_parse_usage") + assert usage_map_return is not None + assert parse_usage_return is not None + + # Also ensure the endpoint routing contract is present in the template. + assert "async def _make_request" in template_text + assert "endpoint: str | None = None" in template_text + assert "def _make_stream_request" in template_text + + return TemplateExpectations( + usage_map_return=usage_map_return, + parse_usage_return=parse_usage_return, + ) + + +def _find_provider_module_dir(provider_pkg: Path) -> Path | None: + src = provider_pkg / "src" + if not src.exists(): + return None + + candidates = [ + p for p in src.iterdir() if p.is_dir() and p.name.startswith("celeste_") + ] + if not candidates: + return None + + expected = f"celeste_{provider_pkg.name}" + for c in candidates: + if c.name == expected: + return c + + return sorted(candidates)[0] + + +def _provider_api_client_files() -> list[Path]: + root = _repo_root() + providers_dir = root / "src" / "celeste" / "providers" + + out: list[Path] = [] + for provider_dir in sorted([p for p in providers_dir.iterdir() if p.is_dir()]): + # Each provider has API subdirs (e.g., openai/responses, openai/images) + for api_dir in sorted([p for p in provider_dir.iterdir() if p.is_dir()]): + client_path = api_dir / "client.py" + if client_path.exists(): + out.append(client_path) + + # Sanity check: we currently expect ~20 provider API modules. + assert len(out) >= 15 + return out + + +def _first_class(tree: ast.Module) -> ast.ClassDef: + for node in tree.body: + if isinstance(node, ast.ClassDef): + return node + msg = "No top-level class found" + raise AssertionError(msg) + + +def _class_methods( + cls: ast.ClassDef, +) -> dict[str, ast.FunctionDef | ast.AsyncFunctionDef]: + methods: dict[str, ast.FunctionDef | ast.AsyncFunctionDef] = {} + for node in cls.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + methods[node.name] = node + return methods + + +def _kwonly_arg( + fn: ast.FunctionDef | ast.AsyncFunctionDef, name: str +) -> tuple[ast.arg, ast.expr | None] | None: + for arg, default in zip(fn.args.kwonlyargs, fn.args.kw_defaults, strict=True): + if arg.arg == name: + return arg, default + return None + + +def _has_staticmethod_decorator(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: + for dec in fn.decorator_list: + if isinstance(dec, ast.Name) and dec.id == "staticmethod": + return True + return False + + +def test_provider_template_contract_is_parseable() -> None: + template_path = _template_client_path() + text = template_path.read_text(encoding="utf-8") + _extract_template_expectations(text) + + +def test_all_provider_api_mixins_match_template_contract() -> None: + template_text = _template_client_path().read_text(encoding="utf-8") + exp = _extract_template_expectations(template_text) + + for client_path in _provider_api_client_files(): + tree = ast.parse(client_path.read_text(encoding="utf-8")) + cls = _first_class(tree) + methods = _class_methods(cls) + + missing = sorted(TEMPLATE_REQUIRED_METHODS - set(methods.keys())) + assert not missing, f"{client_path}: missing methods: {missing}" + + # Endpoint routing contract + make_request = methods["_make_request"] + make_stream = methods["_make_stream_request"] + + for fn_name, fn in ( + ("_make_request", make_request), + ("_make_stream_request", make_stream), + ): + kw = _kwonly_arg(fn, "endpoint") + assert kw is not None, f"{client_path}: {fn_name} missing kw-only endpoint" + endpoint_arg, endpoint_default = kw + + assert endpoint_arg.annotation is not None, ( + f"{client_path}: {fn_name} endpoint missing annotation" + ) + assert ast.unparse(endpoint_arg.annotation).strip() == "str | None", ( + f"{client_path}: {fn_name} endpoint annotation mismatch" + ) + assert ( + isinstance(endpoint_default, ast.Constant) + and endpoint_default.value is None + ), f"{client_path}: {fn_name} endpoint default must be None" + + # Usage typing parity (matches template) + map_usage_fields = methods["map_usage_fields"] + assert _has_staticmethod_decorator(map_usage_fields), ( + f"{client_path}: map_usage_fields must be @staticmethod" + ) + + assert map_usage_fields.returns is not None, ( + f"{client_path}: map_usage_fields missing return annotation" + ) + assert ast.unparse(map_usage_fields.returns).strip() == exp.usage_map_return, ( + f"{client_path}: map_usage_fields return annotation mismatch" + ) + + parse_usage = methods["_parse_usage"] + assert parse_usage.returns is not None, ( + f"{client_path}: _parse_usage missing return annotation" + ) + assert ast.unparse(parse_usage.returns).strip() == exp.parse_usage_return, ( + f"{client_path}: _parse_usage return annotation mismatch" + ) diff --git a/tests/unit_tests/test_stream_metadata_from_response_data.py b/tests/unit_tests/test_stream_metadata_from_response_data.py new file mode 100644 index 00000000..4e337542 --- /dev/null +++ b/tests/unit_tests/test_stream_metadata_from_response_data.py @@ -0,0 +1,194 @@ +"""Unit tests for streaming metadata built from provider response_data.""" + +from collections.abc import AsyncIterator +from typing import Any + +from pydantic import SecretStr + +from celeste import Model +from celeste.auth import AuthHeader +from celeste.core import Modality, Operation, Provider +from celeste.modalities.text.providers.google.client import ( + GoogleTextClient, + GoogleTextStream, +) +from celeste.modalities.text.providers.openai.client import ( + OpenAITextClient, + OpenAITextStream, +) + + +async def _async_iter(items: list[dict[str, Any]]) -> AsyncIterator[dict[str, Any]]: + for item in items: + yield item + + +async def test_openai_stream_builds_metadata_from_inner_response_data() -> None: + model = Model( + id="gpt-4o-mini", + provider=Provider.OPENAI, + display_name="GPT-4o mini", + operations={Modality.TEXT: {Operation.GENERATE}}, + ) + client = OpenAITextClient( + model=model, + provider=Provider.OPENAI, + auth=AuthHeader(secret=SecretStr("test")), + ) + + response_data = { + "status": "completed", + "output": [ + {"type": "message", "content": [{"type": "output_text", "text": "x"}]} + ], + "usage": {"input_tokens": 1, "output_tokens": 2, "total_tokens": 3}, + } + + events: list[dict[str, Any]] = [ + {"type": "response.output_text.delta", "delta": "Hello"}, + {"type": "response.completed", "response": response_data}, + ] + + stream = OpenAITextStream( + _async_iter(events), + transform_output=lambda x, **_: x, + client=client, + **{}, + ) + + async for _ in stream: + pass + + metadata = stream.output.metadata + raw_events = metadata.get("raw_events", []) + assert len(raw_events) == 1 # Only response_data, deltas filtered + assert raw_events[0].get("status") == "completed" + + +async def test_google_stream_builds_metadata_from_event_response_data() -> None: + model = Model( + id="gemini-2.5-pro", + provider=Provider.GOOGLE, + display_name="Gemini 2.5 Pro", + operations={Modality.TEXT: {Operation.GENERATE}}, + ) + client = GoogleTextClient( + model=model, + provider=Provider.GOOGLE, + auth=AuthHeader(secret=SecretStr("test"), header="x-goog-api-key", prefix=""), + ) + + event = { + "candidates": [ + { + "content": {"parts": [{"text": "Hello"}]}, + "finishReason": "STOP", + } + ], + "usageMetadata": { + "promptTokenCount": 1, + "candidatesTokenCount": 2, + "totalTokenCount": 3, + }, + } + + stream = GoogleTextStream( + _async_iter([event]), + transform_output=lambda x, **_: x, + client=client, + **{}, + ) + + async for _ in stream: + pass + + metadata = stream.output.metadata + raw_events = metadata.get("raw_events", []) + assert len(raw_events) == 1 + assert raw_events[0].get("usageMetadata", {}).get("totalTokenCount") == 3 + + +async def test_openai_stream_filters_content_only_events() -> None: + """Test that content-only delta events are filtered from raw_events.""" + model = Model( + id="gpt-4o-mini", + provider=Provider.OPENAI, + display_name="GPT-4o mini", + operations={Modality.TEXT: {Operation.GENERATE}}, + ) + client = OpenAITextClient( + model=model, + provider=Provider.OPENAI, + auth=AuthHeader(secret=SecretStr("test")), + ) + + response_data = { + "status": "completed", + "usage": {"input_tokens": 1, "output_tokens": 2, "total_tokens": 3}, + } + + # Include multiple content-only delta events - these should be filtered + events: list[dict[str, Any]] = [ + {"type": "response.output_text.delta", "delta": "Hello"}, + {"type": "response.output_text.delta", "delta": " world"}, + {"type": "response.output_text.delta", "delta": "!"}, + {"type": "response.completed", "response": response_data}, + ] + + stream = OpenAITextStream( + _async_iter(events), + transform_output=lambda x, **_: x, + client=client, + **{}, + ) + + async for _ in stream: + pass + + raw_events = stream.output.metadata.get("raw_events", []) + # Only the initial response should remain (delta events filtered) + event_types = [e.get("type") for e in raw_events] + assert "response.output_text.delta" not in event_types + + +async def test_openai_stream_aggregates_usage_from_last_event() -> None: + """Test that usage is taken from the last event with usage data.""" + model = Model( + id="gpt-4o-mini", + provider=Provider.OPENAI, + display_name="GPT-4o mini", + operations={Modality.TEXT: {Operation.GENERATE}}, + ) + client = OpenAITextClient( + model=model, + provider=Provider.OPENAI, + auth=AuthHeader(secret=SecretStr("test")), + ) + + # Simulate multiple events with usage (last one should win) + events: list[dict[str, Any]] = [ + {"type": "response.output_text.delta", "delta": "Hello"}, + { + "type": "response.completed", + "response": { + "status": "completed", + "usage": {"input_tokens": 10, "output_tokens": 20, "total_tokens": 30}, + }, + }, + ] + + stream = OpenAITextStream( + _async_iter(events), + transform_output=lambda x, **_: x, + client=client, + **{}, + ) + + async for _ in stream: + pass + + # Usage should be from the completed event + usage = stream.output.usage + assert usage.input_tokens == 10 + assert usage.output_tokens == 20 + assert usage.total_tokens == 30 diff --git a/tests/unit_tests/test_streaming.py b/tests/unit_tests/test_streaming.py index 5b76eb73..d0ca23f7 100644 --- a/tests/unit_tests/test_streaming.py +++ b/tests/unit_tests/test_streaming.py @@ -7,7 +7,7 @@ import pytest from pydantic import Field -from celeste.exceptions import StreamEmptyError, StreamNotExhaustedError +from celeste.exceptions import StreamNotExhaustedError from celeste.io import Chunk, FinishReason, Output, Usage from celeste.parameters import Parameters from celeste.streaming import Stream @@ -476,10 +476,13 @@ async def test_repr_done_state(self) -> None: class TestStreamEmptyStreamError: - """Test Stream empty stream error handling.""" + """Test Stream empty stream handling - empty streams are allowed by design.""" - async def test_empty_stream_raises_runtime_error(self) -> None: - """Stream must raise StreamEmptyError when exhausted with no chunks.""" + async def test_empty_stream_completes_successfully(self) -> None: + """Empty streams complete successfully without raising StreamEmptyError. + + This is by design to support reasoning models that use all tokens for thinking. + """ # Arrange - Create stream where all events return None from _parse_chunk async def empty_iter() -> AsyncIterator[dict[str, Any]]: @@ -488,15 +491,25 @@ async def empty_iter() -> AsyncIterator[dict[str, Any]]: stream = ConcreteStream(empty_iter()) - # Act & Assert - Exhaustion raises StreamEmptyError - with pytest.raises( - StreamEmptyError, match=r"Stream completed but no chunks were produced" - ): - async for _ in stream: - pass + # Act - Empty stream should iterate without raising StreamEmptyError + chunks = [] + async for chunk in stream: + chunks.append(chunk) + + # Assert - No chunks were yielded + assert len(chunks) == 0 + + # Assert - Stream is closed + assert stream._closed + + # Assert - Accessing output raises StreamNotExhaustedError (no output was parsed) + with pytest.raises(StreamNotExhaustedError): + _ = stream.output - async def test_stream_with_only_lifecycle_events_raises_error(self) -> None: - """Stream raises StreamEmptyError when SSE yields events but all chunks are filtered to None.""" + async def test_stream_with_only_lifecycle_events_completes_successfully( + self, + ) -> None: + """Stream with only lifecycle events completes successfully without raising StreamEmptyError.""" # Arrange - Events that all return None from _parse_chunk events = [ {"type": "ping"}, # Lifecycle event (no delta/content) @@ -506,12 +519,20 @@ async def test_stream_with_only_lifecycle_events_raises_error(self) -> None: ] stream = ConcreteStream(_async_iter(events)) - # Act & Assert - Should raise StreamEmptyError when exhausted - with pytest.raises( - StreamEmptyError, match=r"Stream completed but no chunks were produced" - ): - async for _ in stream: - pass + # Act - Stream should iterate without raising StreamEmptyError + chunks = [] + async for chunk in stream: + chunks.append(chunk) + + # Assert - No chunks were yielded + assert len(chunks) == 0 + + # Assert - Stream is closed + assert stream._closed + + # Assert - Accessing output raises StreamNotExhaustedError (no output was parsed) + with pytest.raises(StreamNotExhaustedError): + _ = stream.output class TestStreamExceptionHandling: diff --git a/tests/unit_tests/test_text_media_support_validation.py b/tests/unit_tests/test_text_media_support_validation.py new file mode 100644 index 00000000..ae879b84 --- /dev/null +++ b/tests/unit_tests/test_text_media_support_validation.py @@ -0,0 +1,187 @@ +"""Unit tests for TextClient media support validation.""" + +import pytest +from pydantic import SecretStr + +from celeste import Model +from celeste.artifacts import ImageArtifact, VideoArtifact +from celeste.auth import AuthHeader +from celeste.constraints import ImagesConstraint, VideosConstraint +from celeste.core import InputType, Modality, Operation, Provider +from celeste.modalities.text.parameters import TextParameter +from celeste.modalities.text.providers.google.client import GoogleTextClient + + +@pytest.fixture +def model_with_image_support() -> Model: + """Model that declares image support.""" + return Model( + id="test-vision", + provider=Provider.GOOGLE, + display_name="Test Vision", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, + streaming=True, + parameter_constraints={ + TextParameter.IMAGE: ImagesConstraint(), + }, + ) + + +@pytest.fixture +def model_with_video_support() -> Model: + """Model that declares video support.""" + return Model( + id="test-video", + provider=Provider.GOOGLE, + display_name="Test Video", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, + streaming=True, + parameter_constraints={ + TextParameter.VIDEO: VideosConstraint(), + }, + ) + + +@pytest.fixture +def model_without_media_support() -> Model: + """Model that declares no media support.""" + return Model( + id="test-text-only", + provider=Provider.GOOGLE, + display_name="Test Text Only", + operations={Modality.TEXT: {Operation.GENERATE}}, + streaming=True, + parameter_constraints={}, + ) + + +@pytest.fixture +def google_auth() -> AuthHeader: + """Google auth header.""" + return AuthHeader(secret=SecretStr("test"), header="x-goog-api-key", prefix="") + + +def test_model_optional_input_types_computed_from_constraints( + model_with_image_support: Model, + model_with_video_support: Model, + model_without_media_support: Model, +) -> None: + """Verify optional_input_types is correctly computed from parameter_constraints.""" + assert InputType.IMAGE in model_with_image_support.optional_input_types + assert InputType.VIDEO not in model_with_image_support.optional_input_types + + assert InputType.VIDEO in model_with_video_support.optional_input_types + assert InputType.IMAGE not in model_with_video_support.optional_input_types + + assert InputType.IMAGE not in model_without_media_support.optional_input_types + assert InputType.VIDEO not in model_without_media_support.optional_input_types + + +def test_check_media_support_allows_image_when_declared( + model_with_image_support: Model, + google_auth: AuthHeader, +) -> None: + """Image input should be allowed when model declares ImagesConstraint.""" + client = GoogleTextClient( + model=model_with_image_support, + provider=Provider.GOOGLE, + auth=google_auth, + ) + + # Should not raise + client._check_media_support( + image=ImageArtifact(data=b"test"), + video=None, + audio=None, + ) + + +def test_check_media_support_allows_video_when_declared( + model_with_video_support: Model, + google_auth: AuthHeader, +) -> None: + """Video input should be allowed when model declares VideosConstraint.""" + client = GoogleTextClient( + model=model_with_video_support, + provider=Provider.GOOGLE, + auth=google_auth, + ) + + # Should not raise + client._check_media_support( + image=None, + video=VideoArtifact(data=b"test"), + audio=None, + ) + + +def test_check_media_support_rejects_image_when_not_declared( + model_without_media_support: Model, + google_auth: AuthHeader, +) -> None: + """Image input should raise NotImplementedError when model doesn't declare support.""" + client = GoogleTextClient( + model=model_without_media_support, + provider=Provider.GOOGLE, + auth=google_auth, + ) + + with pytest.raises(NotImplementedError, match="does not support image input"): + client._check_media_support( + image=ImageArtifact(data=b"test"), + video=None, + audio=None, + ) + + +def test_check_media_support_rejects_video_when_not_declared( + model_without_media_support: Model, + google_auth: AuthHeader, +) -> None: + """Video input should raise NotImplementedError when model doesn't declare support.""" + client = GoogleTextClient( + model=model_without_media_support, + provider=Provider.GOOGLE, + auth=google_auth, + ) + + with pytest.raises(NotImplementedError, match="does not support video input"): + client._check_media_support( + image=None, + video=VideoArtifact(data=b"test"), + audio=None, + ) + + +def test_check_media_support_rejects_video_on_image_only_model( + model_with_image_support: Model, + google_auth: AuthHeader, +) -> None: + """Video should be rejected on model that only declares image support.""" + client = GoogleTextClient( + model=model_with_image_support, + provider=Provider.GOOGLE, + auth=google_auth, + ) + + with pytest.raises(NotImplementedError, match="does not support video input"): + client._check_media_support( + image=None, + video=VideoArtifact(data=b"test"), + audio=None, + ) + + +def test_check_media_support_allows_none_values( + model_without_media_support: Model, + google_auth: AuthHeader, +) -> None: + """None values should always be allowed (no media provided).""" + client = GoogleTextClient( + model=model_without_media_support, + provider=Provider.GOOGLE, + auth=google_auth, + ) + + # Should not raise - no media provided + client._check_media_support(image=None, video=None, audio=None) diff --git a/tests/unit_tests/test_text_modality_analyze_image.py b/tests/unit_tests/test_text_modality_analyze_image.py new file mode 100644 index 00000000..4879b50c --- /dev/null +++ b/tests/unit_tests/test_text_modality_analyze_image.py @@ -0,0 +1,264 @@ +"""Unit tests for `TextClient.analyze(image=...)` request building (no network).""" + +import inspect + +from pydantic import SecretStr + +from celeste import Model +from celeste.artifacts import ImageArtifact +from celeste.auth import AuthHeader +from celeste.core import Modality, Operation, Provider +from celeste.mime_types import ImageMimeType +from celeste.modalities.text.io import TextInput +from celeste.modalities.text.providers.anthropic.client import AnthropicTextClient +from celeste.modalities.text.providers.cohere.client import CohereTextClient +from celeste.modalities.text.providers.google.client import GoogleTextClient +from celeste.modalities.text.providers.groq.client import GroqTextClient +from celeste.modalities.text.providers.mistral.client import MistralTextClient +from celeste.modalities.text.providers.moonshot.client import MoonshotTextClient +from celeste.modalities.text.providers.openai.client import OpenAITextClient +from celeste.modalities.text.providers.xai.client import XAITextClient + + +def test_analyze_signatures_accept_image() -> None: + """Ensure all providers accept `image=` (all optional).""" + for client_cls in ( + OpenAITextClient, + GoogleTextClient, + MistralTextClient, + AnthropicTextClient, + CohereTextClient, + GroqTextClient, + MoonshotTextClient, + XAITextClient, + ): + sig = inspect.signature(client_cls.analyze) + params = sig.parameters + + # image is optional keyword-only parameter + assert "image" in params + assert params["image"].default is None # optional + + # No text parameter + assert "text" not in params + + +def test_google_analyze_signature_accepts_video_and_audio() -> None: + """Ensure Google provider accepts `video=` and `audio=` (only Google supports native video/audio).""" + sig = inspect.signature(GoogleTextClient.analyze) + params = sig.parameters + + assert "video" in params + assert params["video"].default is None # optional + assert "audio" in params + assert params["audio"].default is None # optional + + +def test_openai_init_request_includes_input_image_block() -> None: + model = Model( + id="gpt-4o", + provider=Provider.OPENAI, + display_name="GPT-4o", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, + ) + client = OpenAITextClient( + model=model, + provider=Provider.OPENAI, + auth=AuthHeader(secret=SecretStr("test")), + ) + + request = client._init_request( + TextInput( + prompt="Describe the image", + image=ImageArtifact(data=b"abc", mime_type=ImageMimeType.PNG), + ) + ) + + content = request["input"][0]["content"] + assert content[0]["type"] == "input_image" + assert content[0]["image_url"].startswith("data:image/") + assert content[-1] == {"type": "input_text", "text": "Describe the image"} + + +def test_google_init_request_includes_inline_data_part() -> None: + model = Model( + id="gemini-2.5-pro", + provider=Provider.GOOGLE, + display_name="Gemini 2.5 Pro", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, + ) + client = GoogleTextClient( + model=model, + provider=Provider.GOOGLE, + auth=AuthHeader(secret=SecretStr("test"), header="x-goog-api-key", prefix=""), + ) + + request = client._init_request( + TextInput( + prompt="Describe the image", + image=ImageArtifact(data=b"abc", mime_type=ImageMimeType.PNG), + ) + ) + + parts = request["contents"][0]["parts"] + assert "inline_data" in parts[0] + assert parts[0]["inline_data"]["data"] == "YWJj" + assert parts[-1] == {"text": "Describe the image"} + + +def test_mistral_init_request_includes_image_url_block() -> None: + model = Model( + id="pixtral-12b-latest", + provider=Provider.MISTRAL, + display_name="Pixtral 12B", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, + ) + client = MistralTextClient( + model=model, + provider=Provider.MISTRAL, + auth=AuthHeader(secret=SecretStr("test")), + ) + + request = client._init_request( + TextInput( + prompt="Describe the image", + image=ImageArtifact(data=b"abc", mime_type=ImageMimeType.PNG), + ) + ) + + content = request["messages"][0]["content"] + assert content[0]["type"] == "image_url" + assert content[0]["image_url"]["url"].startswith("data:image/") + assert content[-1] == {"type": "text", "text": "Describe the image"} + + +def test_anthropic_init_request_includes_image_source_block() -> None: + model = Model( + id="claude-sonnet-4-5", + provider=Provider.ANTHROPIC, + display_name="Claude Sonnet 4.5", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, + ) + client = AnthropicTextClient( + model=model, + provider=Provider.ANTHROPIC, + auth=AuthHeader(secret=SecretStr("test"), header="x-api-key", prefix=""), + ) + + request = client._init_request( + TextInput( + prompt="Describe the image", + image=ImageArtifact(data=b"abc", mime_type=ImageMimeType.PNG), + ) + ) + + content = request["messages"][0]["content"] + assert content[0]["type"] == "image" + assert content[0]["source"]["type"] == "base64" + assert content[0]["source"]["data"] == "YWJj" + assert content[-1] == {"type": "text", "text": "Describe the image"} + + +def test_cohere_init_request_includes_image_url_block() -> None: + model = Model( + id="command-a-vision-07-2025", + provider=Provider.COHERE, + display_name="Command A Vision", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, + ) + client = CohereTextClient( + model=model, + provider=Provider.COHERE, + auth=AuthHeader(secret=SecretStr("test")), + ) + + request = client._init_request( + TextInput( + prompt="Describe the image", + image=ImageArtifact(data=b"abc", mime_type=ImageMimeType.PNG), + ) + ) + + content = request["messages"][0]["content"] + assert content[0]["type"] == "image_url" + assert content[0]["image_url"]["url"].startswith("data:image/") + assert content[-1] == {"type": "text", "text": "Describe the image"} + + +def test_groq_init_request_includes_image_url_block() -> None: + model = Model( + id="llama-3.2-11b-vision-preview", + provider=Provider.GROQ, + display_name="Llama 3.2 11B Vision", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, + ) + client = GroqTextClient( + model=model, + provider=Provider.GROQ, + auth=AuthHeader(secret=SecretStr("test")), + ) + + request = client._init_request( + TextInput( + prompt="Describe the image", + image=ImageArtifact(data=b"abc", mime_type=ImageMimeType.PNG), + ) + ) + + content = request["messages"][0]["content"] + assert content[0]["type"] == "image_url" + assert content[0]["image_url"]["url"].startswith("data:image/") + assert content[-1] == {"type": "text", "text": "Describe the image"} + + +def test_moonshot_init_request_includes_image_url_block() -> None: + model = Model( + id="moonshot-v1-8k-vision-preview", + provider=Provider.MOONSHOT, + display_name="Moonshot v1 8K Vision", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, + ) + client = MoonshotTextClient( + model=model, + provider=Provider.MOONSHOT, + auth=AuthHeader(secret=SecretStr("test")), + ) + + request = client._init_request( + TextInput( + prompt="Describe the image", + image=ImageArtifact(data=b"abc", mime_type=ImageMimeType.PNG), + ) + ) + + content = request["messages"][0]["content"] + assert content[0]["type"] == "image_url" + assert content[0]["image_url"]["url"].startswith("data:image/") + assert content[-1] == {"type": "text", "text": "Describe the image"} + + +def test_xai_init_request_includes_input_image_block() -> None: + model = Model( + id="grok-4-0709", + provider=Provider.XAI, + display_name="Grok 4", + operations={Modality.TEXT: {Operation.GENERATE, Operation.ANALYZE}}, + ) + client = XAITextClient( + model=model, + provider=Provider.XAI, + auth=AuthHeader(secret=SecretStr("test")), + ) + + request = client._init_request( + TextInput( + prompt="Describe the image", + image=ImageArtifact(data=b"abc", mime_type=ImageMimeType.PNG), + ) + ) + + # Responses API format: input is array of message objects with content + content = request["input"][0]["content"] + assert content[0]["type"] == "input_image" + assert content[0]["image_url"].startswith("data:image/") + assert content[-1] == {"type": "input_text", "text": "Describe the image"} diff --git a/tests/unit_tests/utils/__init__.py b/tests/unit_tests/utils/__init__.py new file mode 100644 index 00000000..d88f8dc4 --- /dev/null +++ b/tests/unit_tests/utils/__init__.py @@ -0,0 +1 @@ +"""Tests for celeste.utils module.""" diff --git a/tests/unit_tests/utils/test_image.py b/tests/unit_tests/utils/test_image.py new file mode 100644 index 00000000..b7a3f64d --- /dev/null +++ b/tests/unit_tests/utils/test_image.py @@ -0,0 +1,229 @@ +"""Tests for celeste.utils.image module.""" + +import struct + +from celeste.utils.image import get_image_dimensions + + +class TestGetImageDimensions: + """Tests for get_image_dimensions function.""" + + # --- PNG Tests --- + + def test_png_dimensions(self) -> None: + """Test PNG dimension extraction.""" + # Minimal PNG header: signature + IHDR chunk with 100x200 dimensions + width, height = 100, 200 + png_header = ( + b"\x89PNG\r\n\x1a\n" # PNG signature (8 bytes) + + struct.pack(">I", 13) # IHDR chunk length (4 bytes) + + b"IHDR" # Chunk type (4 bytes) + + struct.pack(">II", width, height) # Width, Height (8 bytes) + + b"\x08\x06\x00\x00\x00" # Bit depth, color type, etc. (5 bytes) + + b"\x00\x00\x00\x00" # CRC placeholder (4 bytes) + ) + assert get_image_dimensions(png_header) == (width, height) + + def test_png_large_dimensions(self) -> None: + """Test PNG with large dimensions.""" + width, height = 4096, 2160 + png_header = ( + b"\x89PNG\r\n\x1a\n" + + struct.pack(">I", 13) + + b"IHDR" + + struct.pack(">II", width, height) + + b"\x08\x06\x00\x00\x00" + + b"\x00\x00\x00\x00" + ) + assert get_image_dimensions(png_header) == (width, height) + + def test_png_missing_ihdr(self) -> None: + """Test PNG without IHDR chunk returns None.""" + png_header = ( + b"\x89PNG\r\n\x1a\n" + + struct.pack(">I", 13) + + b"XXXX" # Wrong chunk type + + struct.pack(">II", 100, 200) + + b"\x08\x06\x00\x00\x00" + + b"\x00\x00\x00\x00" + ) + assert get_image_dimensions(png_header) is None + + # --- GIF Tests --- + + def test_gif87a_dimensions(self) -> None: + """Test GIF87a dimension extraction.""" + width, height = 320, 240 + gif_header = b"GIF87a" + struct.pack(" None: + """Test GIF89a dimension extraction.""" + width, height = 640, 480 + gif_header = b"GIF89a" + struct.pack(" None: + """Test WebP VP8 (lossy) dimension extraction.""" + width, height = 1280, 720 + # VP8 bitstream: dimensions at bytes 26-30 (14-bit values) + webp_header = ( + b"RIFF" + + struct.pack(" None: + """Test WebP VP8X (extended) dimension extraction.""" + width, height = 1920, 1080 + # VP8X: 24-bit LE width/height at bytes 24-29, stored as value-1 + webp_header = ( + b"RIFF" + + struct.pack(" None: + """Test WebP VP8L (lossless) dimension extraction.""" + width, height = 800, 600 + # VP8L: signature byte + packed 14-bit dimensions + # Bits 0-13: width-1, Bits 14-27: height-1 + w_minus_1 = width - 1 + h_minus_1 = height - 1 + # Pack into 4 bytes with irregular bit layout + b0 = w_minus_1 & 0xFF + b1 = ((w_minus_1 >> 8) & 0x3F) | ((h_minus_1 & 0x03) << 6) + b2 = (h_minus_1 >> 2) & 0xFF + b3 = (h_minus_1 >> 10) & 0x0F + packed = bytes([b0, b1, b2, b3]) + + webp_header = ( + b"RIFF" + + struct.pack(" None: + """Test WebP with unknown chunk type returns None.""" + webp_header = ( + b"RIFF" + + struct.pack(" None: + """Test JPEG baseline (SOF0) dimension extraction.""" + width, height = 1024, 768 + # JPEG: SOI + APP0 + SOF0 with dimensions + jpeg_header = ( + b"\xff\xd8" # SOI marker + + b"\xff\xe0" # APP0 marker + + struct.pack(">H", 16) # APP0 length + + b"JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00" # APP0 data + + b"\xff\xc0" # SOF0 marker (baseline) + + struct.pack(">H", 11) # SOF0 length + + b"\x08" # Precision + + struct.pack(">HH", height, width) # Height, Width (JPEG order) + + b"\x03" # Components + + b"\x00" * 20 + ) + assert get_image_dimensions(jpeg_header) == (width, height) + + def test_jpeg_progressive_dimensions(self) -> None: + """Test JPEG progressive (SOF2) dimension extraction.""" + width, height = 1920, 1080 + jpeg_header = ( + b"\xff\xd8" # SOI marker + + b"\xff\xc2" # SOF2 marker (progressive) + + struct.pack(">H", 11) # SOF2 length + + b"\x08" # Precision + + struct.pack(">HH", height, width) # Height, Width + + b"\x03" + + b"\x00" * 20 + ) + assert get_image_dimensions(jpeg_header) == (width, height) + + def test_jpeg_with_dht_before_sof(self) -> None: + """Test JPEG with DHT marker before SOF (should skip DHT).""" + width, height = 640, 480 + jpeg_header = ( + b"\xff\xd8" # SOI + + b"\xff\xc4" # DHT marker (should be skipped) + + struct.pack(">H", 10) # DHT length + + b"\x00" * 8 # DHT data + + b"\xff\xc0" # SOF0 + + struct.pack(">H", 11) + + b"\x08" + + struct.pack(">HH", height, width) + + b"\x03" + + b"\x00" * 20 + ) + assert get_image_dimensions(jpeg_header) == (width, height) + + # --- Edge Cases --- + + def test_empty_data(self) -> None: + """Test empty data returns None.""" + assert get_image_dimensions(b"") is None + + def test_too_short_data(self) -> None: + """Test data shorter than 24 bytes returns None.""" + assert get_image_dimensions(b"\x89PNG\r\n\x1a\n") is None + assert get_image_dimensions(b"x" * 23) is None + + def test_unknown_format(self) -> None: + """Test unknown format returns None.""" + assert get_image_dimensions(b"unknown format data here!!") is None + + def test_garbage_data(self) -> None: + """Test random garbage returns None.""" + import os + + garbage = os.urandom(100) + # Should not raise, just return None + result = get_image_dimensions(garbage) + assert result is None or isinstance(result, tuple) + + def test_truncated_jpeg(self) -> None: + """Test truncated JPEG returns None.""" + # JPEG header that starts scanning but runs out of data + truncated = b"\xff\xd8\xff\xe0\x00\x10" + b"JFIF" + b"\x00" * 20 + assert get_image_dimensions(truncated) is None + + def test_jpeg_with_sos_before_sof(self) -> None: + """Test JPEG with SOS marker before SOF returns None.""" + # Malformed JPEG with SOS before SOF + jpeg_header = ( + b"\xff\xd8" # SOI + + b"\xff\xda" # SOS marker (stops scanning) + + b"\x00" * 30 + ) + assert get_image_dimensions(jpeg_header) is None