mirror of
https://github.com/calesthio/OpenMontage.git
synced 2026-08-14 20:54:39 +08:00
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:
@@ -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
5
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
80
tools/google_credentials.py
Normal file
80
tools/google_credentials.py
Normal 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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user