Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ jobs:
- uses: ./.github/actions/setup-python-uv
with:
python-version: ${{ matrix.python-version }}
- run: uv sync --extra gcp
- run: uv run pytest tests/unit_tests -v --cov=celeste --cov-report=term-missing --cov-report=xml --cov-report=html --cov-fail-under=80
- uses: codecov/codecov-action@v4
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,3 @@ jobs:
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options

1 change: 0 additions & 1 deletion .github/workflows/claude.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,3 @@ jobs:
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'

1 change: 1 addition & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ jobs:
workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
- uses: ./.github/actions/setup-python-uv
- run: uv sync --extra gcp
- name: Run ${{ matrix.package }} integration tests
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
Expand Down
5 changes: 2 additions & 3 deletions src/celeste/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,9 +296,8 @@ def _handle_error_response(self, response: httpx.Response) -> None:
"""Handle error responses from provider APIs."""
if not response.is_success:
try:
error_data = response.json()
error_msg = error_data.get("error", {}).get("message", response.text)
except JSONDecodeError:
error_msg = response.json()["error"]["message"]
except (JSONDecodeError, KeyError, TypeError, IndexError):
error_msg = response.text or f"HTTP {response.status_code}"

raise httpx.HTTPStatusError(
Expand Down
5 changes: 2 additions & 3 deletions src/celeste/modalities/images/providers/google/gemini.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ def _init_request(self, inputs: ImageInput) -> dict[str, Any]:
parts.append({"text": inputs.prompt})

return {
"contents": [{"parts": parts}],
"contents": [{"role": "user", "parts": parts}],
"generationConfig": {
"responseModalities": ["TEXT", "IMAGE"],
"imageConfig": {},
Expand Down Expand Up @@ -113,8 +113,7 @@ def _parse_content(
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))
artifacts.append(ImageArtifact(data=base64_data, mime_type=mime_type))

if not artifacts:
return ImageArtifact()
Expand Down
4 changes: 1 addition & 3 deletions src/celeste/modalities/images/providers/google/imagen.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""Imagen client for Google images modality."""

import base64
from typing import Any, Unpack

from celeste.artifacts import ImageArtifact
Expand Down Expand Up @@ -61,8 +60,7 @@ def _parse_content(
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))
images.append(ImageArtifact(data=base64_data, mime_type=mime_type))

num_images_requested = parameters.get("num_images")
if num_images_requested == 1:
Expand Down
14 changes: 12 additions & 2 deletions src/celeste/modalities/videos/providers/google/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from typing import Any, Unpack

from celeste.artifacts import VideoArtifact
from celeste.mime_types import VideoMimeType
from celeste.parameters import ParameterMapper
from celeste.providers.google.veo import config
from celeste.providers.google.veo.client import GoogleVeoClient as GoogleVeoMixin
Expand Down Expand Up @@ -51,6 +52,12 @@ def _parse_content(
) -> VideoArtifact:
"""Parse content from response."""
video_data = super()._parse_content(response_data)
# Handle inline base64 response (Vertex can return bytesBase64Encoded)
if "bytesBase64Encoded" in video_data:
mime_type = video_data.get("mimeType", VideoMimeType.MP4)
return VideoArtifact(
data=video_data["bytesBase64Encoded"], mime_type=mime_type
)
return VideoArtifact(url=video_data.get("uri"))

def _parse_finish_reason(self, response_data: dict[str, Any]) -> VideoFinishReason:
Expand All @@ -62,13 +69,16 @@ async def download_content(self, artifact: VideoArtifact) -> VideoArtifact:
"""Download video content from GCS URL.

Args:
artifact: VideoArtifact with URL to download.
artifact: VideoArtifact with URL or inline data to download.

Returns:
VideoArtifact with downloaded bytes data.
"""
if artifact.data is not None:
return artifact

if artifact.url is None:
msg = "Artifact has no URL to download"
msg = "Artifact has no URL or data to download"
raise ValueError(msg)

video_bytes = await super().download_content(artifact.url)
Expand Down
26 changes: 24 additions & 2 deletions src/celeste/providers/anthropic/messages/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from celeste.core import UsageField
from celeste.io import FinishReason
from celeste.mime_types import ApplicationMimeType
from celeste.providers.google.auth import GoogleADC

from . import config

Expand All @@ -22,6 +23,10 @@ class AnthropicMessagesClient(APIMixin):
- _parse_finish_reason() - Extract finish reason from response
- _build_metadata() - Filter content fields

Auth-based endpoint selection:
- GoogleADC auth -> Vertex AI endpoints (Claude on Google Cloud)
- API key auth -> Anthropic API endpoints

Usage:
class AnthropicTextGenerationClient(AnthropicMessagesClient, TextGenerationClient):
def _parse_content(self, response_data, **parameters):
Expand All @@ -32,6 +37,23 @@ def _parse_content(self, response_data, **parameters):
return ""
"""

def _get_vertex_endpoint(
self, anthropic_endpoint: str, streaming: bool = False
) -> str:
"""Map Anthropic endpoint to Vertex AI endpoint."""
if streaming:
return config.VertexAnthropicEndpoint.STREAM_MESSAGE
return config.VertexAnthropicEndpoint.CREATE_MESSAGE

def _build_url(self, endpoint: str, streaming: bool = False) -> str:
"""Build full URL based on auth type."""
if isinstance(self.auth, GoogleADC):
return self.auth.build_url(
self._get_vertex_endpoint(endpoint, streaming=streaming),
model_id=self.model.id,
)
return f"{config.BASE_URL}{endpoint}"

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", [])
Expand Down Expand Up @@ -85,7 +107,7 @@ async def _make_request(
endpoint = config.AnthropicMessagesEndpoint.CREATE_MESSAGE

response = await self.http_client.post(
f"{config.BASE_URL}{endpoint}",
url=self._build_url(endpoint, streaming=False),
headers=headers,
json_body=request_body,
)
Expand All @@ -111,7 +133,7 @@ def _make_stream_request(
endpoint = config.AnthropicMessagesEndpoint.CREATE_MESSAGE

return self.http_client.stream_post(
f"{config.BASE_URL}{endpoint}",
url=self._build_url(endpoint, streaming=True),
headers=headers,
json_body=request_body,
)
Expand Down
7 changes: 7 additions & 0 deletions src/celeste/providers/anthropic/messages/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ class AnthropicMessagesEndpoint(StrEnum):
GET_MODEL = "/v1/models/{model_id}"


class VertexAnthropicEndpoint(StrEnum):
"""Endpoints for Anthropic on Vertex AI."""

CREATE_MESSAGE = "/v1/projects/{project_id}/locations/{location}/publishers/anthropic/models/{model_id}:rawPredict"
STREAM_MESSAGE = "/v1/projects/{project_id}/locations/{location}/publishers/anthropic/models/{model_id}:streamRawPredict"


BASE_URL = "https://api.anthropic.com"

# Required
Expand Down
25 changes: 21 additions & 4 deletions src/celeste/providers/deepseek/chat/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from celeste.core import UsageField
from celeste.io import FinishReason
from celeste.mime_types import ApplicationMimeType
from celeste.providers.google.auth import GoogleADC

from . import config

Expand All @@ -31,6 +32,22 @@ def _parse_content(self, response_data, **parameters):
return self._transform_output(content, **parameters)
"""

def _get_vertex_endpoint(self, deepseek_endpoint: str) -> str:
"""Map DeepSeek endpoint to Vertex AI endpoint."""
mapping: dict[str, str] = {
config.DeepSeekChatEndpoint.CREATE_CHAT: config.VertexDeepSeekEndpoint.CREATE_CHAT,
}
vertex_endpoint = mapping.get(deepseek_endpoint)
if vertex_endpoint is None:
raise ValueError(f"No Vertex AI endpoint mapping for: {deepseek_endpoint}")
return vertex_endpoint

def _build_url(self, endpoint: str) -> str:
"""Build full URL based on auth type."""
if isinstance(self.auth, GoogleADC):
return self.auth.build_url(self._get_vertex_endpoint(endpoint))
return f"{config.BASE_URL}{endpoint}"

def _build_request(
self,
inputs: Any,
Expand Down Expand Up @@ -64,7 +81,7 @@ async def _make_request(
}

response = await self.http_client.post(
f"{config.BASE_URL}{endpoint}",
self._build_url(endpoint),
headers=headers,
json_body=request_body,
)
Expand All @@ -89,7 +106,7 @@ def _make_stream_request(
}

return self.http_client.stream_post(
f"{config.BASE_URL}{endpoint}",
self._build_url(endpoint),
headers=headers,
json_body=request_body,
)
Expand All @@ -100,8 +117,8 @@ def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | float | None

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", {})
prompt_tokens_details = usage_data.get("prompt_tokens_details") or {}
completion_tokens_details = usage_data.get("completion_tokens_details") or {}
return {
UsageField.INPUT_TOKENS: usage_data.get("prompt_tokens"),
UsageField.OUTPUT_TOKENS: usage_data.get("completion_tokens"),
Expand Down
6 changes: 6 additions & 0 deletions src/celeste/providers/deepseek/chat/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,10 @@ class DeepSeekChatEndpoint(StrEnum):
LIST_MODELS = "/models"


class VertexDeepSeekEndpoint(StrEnum):
"""Endpoints for DeepSeek on Vertex AI (OpenAI-compatible)."""

CREATE_CHAT = "/v1/projects/{project_id}/locations/{location}/endpoints/openapi/chat/completions"


BASE_URL = "https://api.deepseek.com"
25 changes: 25 additions & 0 deletions src/celeste/providers/google/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,5 +68,30 @@ def get_vertex_base_url(self) -> str:
return VERTEX_GLOBAL_BASE_URL
return VERTEX_BASE_URL.format(location=self.location)

def build_url(self, vertex_endpoint: str, model_id: str | None = None) -> str:
"""Build a complete Vertex AI URL from an endpoint template.

Args:
vertex_endpoint: Endpoint template with {project_id}, {location}, and
optionally {model_id} placeholders.
model_id: Model identifier for endpoints that include it in the path.
"""
project_id = self.resolved_project_id
if project_id is None:
raise ValueError(
"Vertex AI requires a project_id. "
"Pass project_id to GoogleADC() or ensure credentials have a project."
)
base_url = self.get_vertex_base_url()
if model_id is not None:
endpoint = vertex_endpoint.format(
project_id=project_id, location=self.location, model_id=model_id
)
else:
endpoint = vertex_endpoint.format(
project_id=project_id, location=self.location
)
return f"{base_url}{endpoint}"


__all__ = ["GoogleADC"]
3 changes: 2 additions & 1 deletion src/celeste/providers/google/cloud_tts/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | float | None
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())
if not isinstance(self.auth, GoogleADC):
object.__setattr__(self, "auth", GoogleADC())

async def _make_request(
self,
Expand Down
Loading
Loading