Skip to content
Closed
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
21 changes: 21 additions & 0 deletions packages/providers/google/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
[project]
name = "celeste-google"
version = "0.1.0"
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"]

[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"]
1 change: 1 addition & 0 deletions packages/providers/google/src/celeste_google/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Google provider package for Celeste AI."""
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Google GenerateContent API provider package."""
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""Google GenerateContent API client with shared implementation."""

from collections.abc import AsyncIterator
from typing import Any

import httpx

from celeste.core import UsageField
from celeste.io import FinishReason
from celeste.mime_types import ApplicationMimeType

from . import config


class GoogleGenerateContentClient:
"""Mixin for GenerateContent API capabilities.

Provides shared implementation for all capabilities using the GenerateContent API:
- _make_request() - HTTP POST to generateContent
- _make_stream_request() - HTTP streaming to streamGenerateContent
- _parse_usage() - Extract usage dict from usageMetadata
- _parse_content() - Extract parts array from first candidate
- _parse_finish_reason() - Extract finish reason string from candidates
- _build_metadata() - Filter content fields

Capability clients extend parsing methods via super() to wrap/transform results.

Usage:
class GoogleTextGenerationClient(GoogleGenerateContentClient, TextGenerationClient):
def _parse_content(self, response_data, **parameters):
parts = super()._parse_content(response_data)
text = parts[0].get("text") or ""
return self._transform_output(text, **parameters)
"""

async def _make_request(
self,
request_body: dict[str, Any],
**parameters: Any,
) -> httpx.Response:
"""Make HTTP request to generateContent endpoint."""
endpoint = config.GoogleGenerateContentEndpoint.GENERATE_CONTENT.format(
model_id=self.model.id # type: ignore[attr-defined]
)
headers = {
**self.auth.get_headers(), # type: ignore[attr-defined]
"Content-Type": ApplicationMimeType.JSON,
}
return await self.http_client.post( # type: ignore[attr-defined,no-any-return]
f"{config.BASE_URL}{endpoint}",
headers=headers,
json_body=request_body,
)

def _make_stream_request(
self,
request_body: dict[str, Any],
**parameters: Any,
) -> AsyncIterator[dict[str, Any]]:
"""Make streaming request to streamGenerateContent endpoint."""
endpoint = config.GoogleGenerateContentEndpoint.STREAM_GENERATE_CONTENT.format(
model_id=self.model.id # type: ignore[attr-defined]
)
headers = {
**self.auth.get_headers(), # type: ignore[attr-defined]
"Content-Type": ApplicationMimeType.JSON,
}
return self.http_client.stream_post( # type: ignore[attr-defined,no-any-return]
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]:
"""Map Google Gemini usage fields to unified names.

Shared by client and streaming across all capabilities.
"""
return {
UsageField.INPUT_TOKENS: usage_data.get("promptTokenCount"),
UsageField.OUTPUT_TOKENS: usage_data.get("candidatesTokenCount"),
UsageField.TOTAL_TOKENS: usage_data.get("totalTokenCount"),
UsageField.REASONING_TOKENS: usage_data.get("thoughtsTokenCount"),
}

def _parse_usage(self, response_data: dict[str, Any]) -> dict[str, int | None]:
"""Extract usage data from Gemini usageMetadata."""
usage_metadata = response_data.get("usageMetadata", {})
return self.map_usage_fields(usage_metadata)

def _parse_content(self, response_data: dict[str, Any]) -> Any:
"""Return all candidates from response.

Returns list of candidate objects that capability clients extract content from.
"""
candidates = response_data.get("candidates", [])
if not candidates:
msg = "No candidates in response"
raise ValueError(msg)
return candidates

def _parse_finish_reason(self, response_data: dict[str, Any]) -> FinishReason:
"""Extract finish reason from Gemini candidates.

Returns FinishReason that capability clients wrap in their specific type.
"""
candidates = response_data.get("candidates", [])
if not candidates:
reason = None
else:
reason = candidates[0].get("finishReason")
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 = {"candidates"}
filtered_data = {
k: v for k, v in response_data.items() if k not in content_fields
}
return super()._build_metadata(filtered_data) # type: ignore[misc,no-any-return]


__all__ = ["GoogleGenerateContentClient"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""Configuration for Google GenerateContent API."""

from enum import StrEnum


class GoogleGenerateContentEndpoint(StrEnum):
"""Endpoints for GenerateContent API."""

GENERATE_CONTENT = "/v1beta/models/{model_id}:generateContent"
STREAM_GENERATE_CONTENT = "/v1beta/models/{model_id}:streamGenerateContent?alt=sse"
COUNT_TOKENS = "/v1beta/models/{model_id}:countTokens"
EMBED_CONTENT = "/v1beta/models/{model_id}:embedContent"
BATCH_EMBED_CONTENTS = "/v1beta/models/{model_id}:batchEmbedContents"
LIST_MODELS = "/v1beta/models"
GET_MODEL = "/v1beta/models/{model_id}"
UPLOAD_FILE = "/upload/v1beta/files"
LIST_FILES = "/v1beta/files"
GET_FILE = "/v1beta/files/{file_id}"
DELETE_FILE = "/v1beta/files/{file_id}"
BATCH_GENERATE_CONTENT = "/v1beta/models/{model_id}:batchGenerateContent"


BASE_URL = "https://generativelanguage.googleapis.com"
Loading
Loading