feat: add isolated Gemini provider

This commit is contained in:
2026-07-30 17:57:19 +02:00
parent 286d96da33
commit f3e11831fb
8 changed files with 890 additions and 19 deletions
+30 -4
View File
@@ -12,12 +12,18 @@ class ChatMessage:
content: str
@dataclass(frozen=True, slots=True)
class ProviderUsage:
input_tokens: int | None = None
output_tokens: int | None = None
class ProviderError(RuntimeError):
"""Base class for expected local provider failures."""
"""Base class for expected provider failures."""
class ProviderUnavailableError(ProviderError):
"""The local model runtime cannot be reached."""
"""The selected model runtime cannot be reached."""
class ModelNotInstalledError(ProviderError):
@@ -25,7 +31,7 @@ class ModelNotInstalledError(ProviderError):
class ProviderTimeoutError(ProviderError):
"""The local model did not respond in time."""
"""The model did not respond in time."""
class ResponseAbortedError(ProviderError):
@@ -33,7 +39,27 @@ class ResponseAbortedError(ProviderError):
class InvalidProviderResponseError(ProviderError):
"""The local runtime returned an unusable response."""
"""The runtime returned an unusable response."""
class MissingApiKeyError(ProviderError):
"""No API key is available for the selected cloud provider."""
class InvalidApiKeyError(ProviderError):
"""The cloud provider rejected its API key."""
class ProviderRateLimitError(ProviderError):
"""The free provider quota or rate limit is exhausted."""
class CloudNetworkError(ProviderError):
"""A cloud request failed at the network layer."""
class CloudModelUnavailableError(ProviderError):
"""The configured cloud model is unavailable."""
@runtime_checkable
+128
View File
@@ -0,0 +1,128 @@
"""Gemini provider using Google's official GA Python SDK."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
import httpx
from google import genai
from google.genai import errors, types
from javis.providers.base import (
ChatMessage,
CloudModelUnavailableError,
CloudNetworkError,
InvalidApiKeyError,
InvalidProviderResponseError,
MissingApiKeyError,
ProviderError,
ProviderRateLimitError,
ProviderTimeoutError,
ProviderUnavailableError,
ProviderUsage,
)
ClientFactory = Callable[..., Any]
class GeminiProvider:
name = "gemini"
def __init__(
self,
*,
model: str,
api_key: str | None,
timeout_seconds: float,
max_output_tokens: int,
max_retries: int,
client_factory: ClientFactory = genai.Client,
) -> None:
if not api_key:
raise MissingApiKeyError("Für Gemini ist kein API-Schlüssel hinterlegt.")
if max_retries not in {0, 1}:
raise ValueError("Gemini darf höchstens einen Wiederholungsversuch verwenden.")
self.model = model
self.last_usage = ProviderUsage()
self._config = types.GenerateContentConfig(
candidate_count=1,
max_output_tokens=max_output_tokens,
)
self._client = client_factory(
api_key=api_key,
http_options=types.HttpOptions(
api_version="v1",
timeout=max(1, round(timeout_seconds * 1_000)),
retry_options=types.HttpRetryOptions(
attempts=max_retries + 1,
http_status_codes=[408, 500, 502, 503, 504],
),
),
)
def chat(self, messages: list[ChatMessage]) -> str:
contents = [
types.Content(
role="model" if message.role == "assistant" else "user",
parts=[types.Part.from_text(text=message.content)],
)
for message in messages
]
self.last_usage = ProviderUsage()
try:
response = self._client.models.generate_content(
model=self.model,
contents=contents,
config=self._config,
)
except errors.APIError as exc:
self._raise_api_error(exc)
except httpx.TimeoutException as exc:
raise ProviderTimeoutError("Gemini hat nicht rechtzeitig geantwortet.") from exc
except httpx.NetworkError as exc:
raise CloudNetworkError(
"Gemini ist wegen eines Netzwerkfehlers nicht erreichbar."
) from exc
except (OSError, ConnectionError) as exc:
raise CloudNetworkError(
"Gemini ist wegen eines Netzwerkfehlers nicht erreichbar."
) from exc
text = getattr(response, "text", None)
if not isinstance(text, str) or not text.strip():
raise InvalidProviderResponseError(
"Gemini hat keine verwendbare Textantwort geliefert."
)
usage = getattr(response, "usage_metadata", None)
self.last_usage = ProviderUsage(
input_tokens=self._token_count(usage, "prompt_token_count"),
output_tokens=self._token_count(usage, "candidates_token_count"),
)
return text.strip()
@staticmethod
def _token_count(usage: object, name: str) -> int | None:
value = getattr(usage, name, None)
return value if isinstance(value, int) and value >= 0 else None
@staticmethod
def _raise_api_error(exc: errors.APIError) -> None:
if exc.code in {401, 403}:
raise InvalidApiKeyError("Der Gemini-API-Schlüssel wurde abgelehnt.") from exc
if exc.code == 429:
raise ProviderRateLimitError(
"Das kostenlose Gemini-Kontingent ist vorübergehend ausgeschöpft."
) from exc
if exc.code == 404:
raise CloudModelUnavailableError(
"Das konfigurierte Gemini-Modell ist nicht verfügbar."
) from exc
if exc.code == 408:
raise ProviderTimeoutError("Gemini hat nicht rechtzeitig geantwortet.") from exc
if exc.code >= 500:
raise ProviderUnavailableError("Gemini ist vorübergehend nicht verfügbar.") from exc
raise ProviderError(f"Gemini-Anfrage fehlgeschlagen (HTTP {exc.code}).") from exc