Merge pull request #132 from shubham21155102/feat/google-service-account-auth

feat(google): service-account auth for TTS + Imagen (Vertex AI), fix false-availability bugs
This commit is contained in:
Calesthio
2026-06-23 12:00:56 -07:00
committed by GitHub
8 changed files with 216 additions and 31 deletions

View File

@@ -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

5
.gitignore vendored
View File

@@ -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

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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)

View File

@@ -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,

View File

@@ -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