diff --git a/.env.example b/.env.example index cb32576..f523311 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,11 @@ FAL_KEY= # FLUX images, Google Veo video, Kling video, MiniM # --- Google (one key unlocks image gen + TTS) --- GOOGLE_API_KEY= # Google Imagen images, Google Cloud TTS (700+ voices, 50+ languages) # Get one at https://aistudio.google.com/apikey +# Alternative to the API key: service-account JSON auth. +# TTS uses Cloud Text-to-Speech; Imagen routes to Vertex AI. +GOOGLE_APPLICATION_CREDENTIALS= # path to a service-account JSON key file +GOOGLE_CLOUD_PROJECT= # GCP project id (required for Imagen via Vertex AI) +GOOGLE_CLOUD_LOCATION= # Vertex AI region, default us-central1 # --- Voice --- ELEVENLABS_API_KEY= # TTS narration, music generation, sound effects diff --git a/.gitignore b/.gitignore index c22b62d..1fadea2 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,11 @@ music_library/ .env.local *.env .youtube-token.json +# Google service-account / Vertex AI keys — never commit +gcp-*.json +*-service-account.json +service-account*.json +service_account*.json # Stray media downloads (test/scratch clips left in repo root) pexels_video_*.mp4 diff --git a/requirements.txt b/requirements.txt index 026ad93..8600794 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ jsonschema>=4.20 python-dotenv>=1.0 Pillow>=10.0 requests>=2.31 +google-auth>=2.0 # service-account auth for Google TTS + Imagen (Vertex AI) diff --git a/tools/audio/google_tts.py b/tools/audio/google_tts.py index 9e1fee2..91e4848 100644 --- a/tools/audio/google_tts.py +++ b/tools/audio/google_tts.py @@ -24,6 +24,7 @@ from tools.base_tool import ( ToolStatus, ToolTier, ) +from tools.google_credentials import get_access_token, service_account_configured class GoogleTTS(BaseTool): @@ -39,9 +40,11 @@ class GoogleTTS(BaseTool): dependencies = [] install_instructions = ( - "Set GOOGLE_API_KEY to your Google Cloud API key with Text-to-Speech enabled.\n" + "Auth option A — API key: set GOOGLE_API_KEY (or GEMINI_API_KEY) to a\n" + " Google Cloud API key with Text-to-Speech enabled.\n" " Enable the API at https://console.cloud.google.com/apis/library/texttospeech.googleapis.com\n" - " Or use GOOGLE_APPLICATION_CREDENTIALS for service account auth." + "Auth option B — service account: set GOOGLE_APPLICATION_CREDENTIALS to the\n" + " path of a service-account JSON key (needs the 'google-auth' package)." ) fallback = "openai_tts" fallback_tools = ["openai_tts", "elevenlabs_tts", "piper_tts"] @@ -130,7 +133,9 @@ class GoogleTTS(BaseTool): return os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY") def get_status(self) -> ToolStatus: - if self._get_api_key() or os.environ.get("GOOGLE_APPLICATION_CREDENTIALS"): + # Available via either an API key or a service-account JSON. Both paths + # are honoured by execute() — so this no longer over-reports. + if self._get_api_key() or service_account_configured(): return ToolStatus.AVAILABLE return ToolStatus.UNAVAILABLE @@ -159,16 +164,26 @@ class GoogleTTS(BaseTool): return round(char_count * rate_per_char, 4) def execute(self, inputs: dict[str, Any]) -> ToolResult: + # Prefer an API key (cheapest path); otherwise mint a Bearer token from + # the service-account JSON. This is what makes + # GOOGLE_APPLICATION_CREDENTIALS actually work for TTS. api_key = self._get_api_key() + bearer_token: str | None = None if not api_key: - return ToolResult( - success=False, - error="No Google API key found. " + self.install_instructions, - ) + if service_account_configured(): + try: + bearer_token, _ = get_access_token() + except RuntimeError as exc: + return ToolResult(success=False, error=str(exc)) + else: + return ToolResult( + success=False, + error="No Google credentials found. " + self.install_instructions, + ) start = time.time() try: - result = self._generate(inputs, api_key) + result = self._generate(inputs, api_key=api_key, bearer_token=bearer_token) except Exception as exc: return ToolResult(success=False, error=f"Google TTS failed: {exc}") @@ -176,7 +191,12 @@ class GoogleTTS(BaseTool): result.cost_usd = self.estimate_cost(inputs) return result - def _generate(self, inputs: dict[str, Any], api_key: str) -> ToolResult: + def _generate( + self, + inputs: dict[str, Any], + api_key: str | None = None, + bearer_token: str | None = None, + ) -> ToolResult: import requests text = inputs["text"] @@ -203,10 +223,17 @@ class GoogleTTS(BaseTool): api_version = "v1beta1" if self._needs_beta_api(voice_name) else "v1" url = f"https://texttospeech.googleapis.com/{api_version}/text:synthesize" + headers = {"Content-Type": "application/json"} + params: dict[str, str] = {} + if bearer_token: + headers["Authorization"] = f"Bearer {bearer_token}" + else: + params["key"] = api_key + response = requests.post( url, - headers={"Content-Type": "application/json"}, - params={"key": api_key}, + headers=headers, + params=params, json=payload, timeout=120, ) diff --git a/tools/base_tool.py b/tools/base_tool.py index f9300d4..49f3ecc 100644 --- a/tools/base_tool.py +++ b/tools/base_tool.py @@ -30,6 +30,7 @@ def _load_dotenv() -> None: env_path = Path(__file__).resolve().parent.parent / ".env" if not env_path.is_file(): return + import re with open(env_path, encoding="utf-8", errors="ignore") as f: for line in f: line = line.strip() @@ -37,13 +38,19 @@ def _load_dotenv() -> None: continue key, _, value = line.partition("=") key = key.strip() - value = value.strip().strip("'\"") - # Strip inline comments: VAR=value # comment - # But only if the # is preceded by whitespace (avoid stripping from values like colors) - if " #" in value: - value = value[:value.index(" #")].rstrip() - elif "\t#" in value: - value = value[:value.index("\t#")].rstrip() + value = value.strip() + # Quoted value: take the content inside the quotes verbatim. + if value[:1] in ("'", '"'): + quote = value[0] + end = value.find(quote, 1) + value = value[1:end] if end != -1 else value[1:] + else: + # Strip an inline comment ('#' at line start or after + # whitespace) so "VAR= # note" yields "" not "# note". + match = re.search(r"(^|\s)#", value) + if match: + value = value[: match.start()] + value = value.strip() if key and key not in os.environ: os.environ[key] = value diff --git a/tools/google_credentials.py b/tools/google_credentials.py new file mode 100644 index 0000000..6336bc2 --- /dev/null +++ b/tools/google_credentials.py @@ -0,0 +1,80 @@ +"""Shared Google service-account authentication for OpenMontage tools. + +Lets the Google provider tools (``google_tts``, ``google_imagen``) +authenticate with a service-account JSON key file via OAuth Bearer tokens — +in addition to the existing API-key path. This is what makes +``GOOGLE_APPLICATION_CREDENTIALS`` actually work end to end. + +The ``google-auth`` package is imported lazily so this module never adds an +import-time cost for tools that only use API keys, and so a missing dependency +surfaces as an actionable runtime error rather than a hard import failure. +""" + +from __future__ import annotations + +import os + +# Broad scope that covers Cloud Text-to-Speech and Vertex AI prediction. +CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform" + + +def service_account_configured() -> bool: + """True when GOOGLE_APPLICATION_CREDENTIALS points to an existing file.""" + path = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS") + return bool(path and os.path.exists(path)) + + +def resolve_project_id(creds_project_id: str | None = None) -> str | None: + """Resolve the GCP project id from env vars, falling back to the key file's. + + Vertex AI needs an explicit project id; TTS does not. We prefer an explicit + env override so users can target a project other than the key's own. + """ + return ( + os.environ.get("GOOGLE_CLOUD_PROJECT") + or os.environ.get("GOOGLE_CLOUD_PROJECT_ID") + or os.environ.get("GCLOUD_PROJECT") + or creds_project_id + ) + + +def get_access_token(scopes: list[str] | None = None) -> tuple[str, str | None]: + """Mint an OAuth access token from the service-account JSON. + + Returns ``(access_token, project_id)``. ``project_id`` is the one embedded + in the key file (callers should still prefer :func:`resolve_project_id`). + + Raises: + RuntimeError: if ``google-auth`` is missing or the credentials cannot + be loaded/refreshed — with a message the agent can surface verbatim. + """ + if scopes is None: + scopes = [CLOUD_PLATFORM_SCOPE] + + try: + from google.auth.transport.requests import Request + from google.oauth2 import service_account + except ImportError as exc: # pragma: no cover - depends on optional dep + raise RuntimeError( + "Service-account auth requires the 'google-auth' package. " + "Install it with: pip install google-auth" + ) from exc + + path = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS") + if not path or not os.path.exists(path): + raise RuntimeError( + "GOOGLE_APPLICATION_CREDENTIALS is not set or points to a missing " + "file; cannot use service-account authentication." + ) + + try: + creds = service_account.Credentials.from_service_account_file( + path, scopes=scopes + ) + creds.refresh(Request()) + except Exception as exc: # noqa: BLE001 - re-raised as actionable message + raise RuntimeError( + f"Failed to load/refresh service-account credentials from {path}: {exc}" + ) from exc + + return creds.token, getattr(creds, "project_id", None) diff --git a/tools/graphics/google_imagen.py b/tools/graphics/google_imagen.py index b8957db..72eebba 100644 --- a/tools/graphics/google_imagen.py +++ b/tools/graphics/google_imagen.py @@ -20,6 +20,11 @@ from tools.base_tool import ( ToolStatus, ToolTier, ) +from tools.google_credentials import ( + get_access_token, + resolve_project_id, + service_account_configured, +) # Aspect ratio to approximate pixel dimensions (for cost/reporting only) ASPECT_RATIOS = { @@ -57,8 +62,12 @@ class GoogleImagen(BaseTool): dependencies = [] # checked dynamically via env var install_instructions = ( - "Set GOOGLE_API_KEY (or GEMINI_API_KEY) to your Google AI API key.\n" - " Get one at https://aistudio.google.com/apikey" + "Auth option A — API key (AI Studio): set GOOGLE_API_KEY (or GEMINI_API_KEY).\n" + " Get one at https://aistudio.google.com/apikey\n" + "Auth option B — service account (Vertex AI): set GOOGLE_APPLICATION_CREDENTIALS\n" + " to a service-account JSON key (needs the 'google-auth' package), plus\n" + " GOOGLE_CLOUD_PROJECT and optionally GOOGLE_CLOUD_LOCATION (default us-central1).\n" + " Requires the Vertex AI API enabled and billing on the project." ) agent_skills = [] @@ -131,7 +140,8 @@ class GoogleImagen(BaseTool): return os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY") def get_status(self) -> ToolStatus: - if self._get_api_key(): + # API key -> AI Studio endpoint; service-account JSON -> Vertex AI. + if self._get_api_key() or service_account_configured(): return ToolStatus.AVAILABLE return ToolStatus.UNAVAILABLE @@ -145,12 +155,31 @@ class GoogleImagen(BaseTool): return 0.04 * n def execute(self, inputs: dict[str, Any]) -> ToolResult: + # Two auth paths: an AI Studio API key, or a service-account JSON that + # routes to Vertex AI (the AI Studio endpoint does not accept service + # accounts). API key wins when both are present. api_key = self._get_api_key() + bearer_token: str | None = None + project_id: str | None = None if not api_key: - return ToolResult( - success=False, - error="No Google API key found. " + self.install_instructions, - ) + if not service_account_configured(): + return ToolResult( + success=False, + error="No Google credentials found. " + self.install_instructions, + ) + try: + bearer_token, creds_project = get_access_token() + except RuntimeError as exc: + return ToolResult(success=False, error=str(exc)) + project_id = resolve_project_id(creds_project) + if not project_id: + return ToolResult( + success=False, + error=( + "Vertex AI needs a project id. Set GOOGLE_CLOUD_PROJECT " + "(or include project_id in the service-account key)." + ), + ) import requests @@ -181,13 +210,31 @@ class GoogleImagen(BaseTool): "aspectRatio": aspect_ratio, } + if bearer_token: + location = os.environ.get("GOOGLE_CLOUD_LOCATION", "us-central1") + url = ( + f"https://{location}-aiplatform.googleapis.com/v1/projects/" + f"{project_id}/locations/{location}/publishers/google/models/" + f"{model}:predict" + ) + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {bearer_token}", + } + else: + url = ( + f"https://generativelanguage.googleapis.com/v1beta/models/" + f"{model}:predict" + ) + headers = { + "Content-Type": "application/json", + "x-goog-api-key": api_key, + } + try: response = requests.post( - f"https://generativelanguage.googleapis.com/v1beta/models/{model}:predict", - headers={ - "Content-Type": "application/json", - "x-goog-api-key": api_key, - }, + url, + headers=headers, json={ "instances": [{"prompt": prompt}], "parameters": parameters, diff --git a/tools/tool_registry.py b/tools/tool_registry.py index 4c61d68..8c96ff9 100644 --- a/tools/tool_registry.py +++ b/tools/tool_registry.py @@ -91,6 +91,7 @@ class ToolRegistry: env_path = Path(__file__).resolve().parent.parent / ".env" if not env_path.is_file(): return + import re with open(env_path, encoding="utf-8", errors="ignore") as f: for line in f: line = line.strip() @@ -98,7 +99,19 @@ class ToolRegistry: continue key, _, value = line.partition("=") key = key.strip() - value = value.strip().strip("'\"") + value = value.strip() + # Quoted value: take the content inside the quotes verbatim. + if value[:1] in ("'", '"'): + quote = value[0] + end = value.find(quote, 1) + value = value[1:end] if end != -1 else value[1:] + else: + # Strip an inline comment ('#' at line start or after + # whitespace) so "KEY= # note" yields "" not "# note". + match = re.search(r"(^|\s)#", value) + if match: + value = value[: match.start()] + value = value.strip() if key and key not in os.environ: os.environ[key] = value